utils-flask-sqlalchemy 0.4.5__tar.gz → 0.5.0__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.
- {utils_flask_sqlalchemy-0.4.5/src/utils_flask_sqlalchemy.egg-info → utils_flask_sqlalchemy-0.5.0}/PKG-INFO +8 -14
- utils_flask_sqlalchemy-0.5.0/VERSION +1 -0
- utils_flask_sqlalchemy-0.5.0/pyproject.toml +76 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/commands.py +6 -4
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/fulltext.py +107 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/generic.py +4 -3
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/migrations/versions/1d09a9b67970_add_functions_to_fetch_dependent_view_.py +185 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/referential.py +102 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/revision.py +51 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/schema.py +7 -8
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/serializers.py +4 -1
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/fixtures.py +10 -24
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/__init__.py +0 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/conftest.py +88 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/test_fulltext.py +80 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/test_referential.py +112 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/test_revision.py +127 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/test_fulltext.py +51 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/test_generic.py +441 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_temporary_transaction.py +8 -0
- utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/utils.py +56 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqlalchemy.egg-info}/PKG-INFO +8 -14
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/SOURCES.txt +11 -2
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/requires.txt +3 -1
- utils_flask_sqlalchemy-0.4.5/VERSION +0 -1
- utils_flask_sqlalchemy-0.4.5/pyproject.toml +0 -13
- utils_flask_sqlalchemy-0.4.5/requirements.in +0 -6
- utils_flask_sqlalchemy-0.4.5/setup.py +0 -54
- utils_flask_sqlalchemy-0.4.5/src/utils_flask_sqla/tests/utils.py +0 -28
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/LICENSE +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/MANIFEST.in +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/README.md +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/setup.cfg +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/__init__.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/db.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/env.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/errors.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/__init__.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/utils.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/versions/3842a6d800a0_sql_utils.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/versions/__init__.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/versions/ba207b468e31_create_fr_numeric_collation.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/models.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/response.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/__init__.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/plugin.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_ordered.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_qfilter.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_schema.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_serializers.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/utils.py +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/dependency_links.txt +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/entry_points.txt +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/top_level.txt +0 -0
- {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/tox.ini +0 -0
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: utils-flask-sqlalchemy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python lib of tools for Flask and SQLAlchemy
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Maintainer-email: Parcs nationaux des Écrins et des Cévennes <geonature@ecrins-parcnational.fr>
|
|
6
|
+
License: GNU Affero General Public License v3
|
|
7
|
+
Project-URL: Homepage, https://github.com/PnX-SI/Utils-Flask-SQLAlchemy
|
|
8
8
|
Classifier: Development Status :: 1 - Planning
|
|
9
9
|
Classifier: Intended Audience :: Developers
|
|
10
10
|
Classifier: Natural Language :: English
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
14
15
|
Description-Content-Type: text/markdown
|
|
15
16
|
License-File: LICENSE
|
|
16
17
|
Requires-Dist: flask
|
|
@@ -18,7 +19,7 @@ Requires-Dist: flask-sqlalchemy
|
|
|
18
19
|
Requires-Dist: flask-migrate
|
|
19
20
|
Requires-Dist: marshmallow
|
|
20
21
|
Requires-Dist: python-dateutil
|
|
21
|
-
Requires-Dist: sqlalchemy<
|
|
22
|
+
Requires-Dist: sqlalchemy<3
|
|
22
23
|
Provides-Extra: tests
|
|
23
24
|
Requires-Dist: pytest; extra == "tests"
|
|
24
25
|
Requires-Dist: geoalchemy2; extra == "tests"
|
|
@@ -26,16 +27,9 @@ Requires-Dist: shapely; extra == "tests"
|
|
|
26
27
|
Requires-Dist: jsonschema; extra == "tests"
|
|
27
28
|
Requires-Dist: flask-marshmallow; extra == "tests"
|
|
28
29
|
Requires-Dist: marshmallow-sqlalchemy; extra == "tests"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Dynamic: description-content-type
|
|
32
|
-
Dynamic: home-page
|
|
30
|
+
Requires-Dist: psycopg2-binary; extra == "tests"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "tests"
|
|
33
32
|
Dynamic: license-file
|
|
34
|
-
Dynamic: maintainer
|
|
35
|
-
Dynamic: maintainer-email
|
|
36
|
-
Dynamic: provides-extra
|
|
37
|
-
Dynamic: requires-dist
|
|
38
|
-
Dynamic: summary
|
|
39
33
|
|
|
40
34
|
## Librairie "outil" pour SQLAlchemy et Flask
|
|
41
35
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.5.0
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "utils-flask-sqlalchemy"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python lib of tools for Flask and SQLAlchemy"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "GNU Affero General Public License v3" }
|
|
12
|
+
maintainers = [
|
|
13
|
+
{ name = "Parcs nationaux des Écrins et des Cévennes", email = "geonature@ecrins-parcnational.fr" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 1 - Planning",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Natural Language :: English",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"License :: OSI Approved :: GNU Affero General Public License v3",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"flask",
|
|
25
|
+
"flask-sqlalchemy",
|
|
26
|
+
"flask-migrate",
|
|
27
|
+
"marshmallow",
|
|
28
|
+
"python-dateutil",
|
|
29
|
+
"sqlalchemy<3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
tests = [
|
|
34
|
+
"pytest",
|
|
35
|
+
"geoalchemy2",
|
|
36
|
+
"shapely",
|
|
37
|
+
"jsonschema",
|
|
38
|
+
"flask-marshmallow",
|
|
39
|
+
"marshmallow-sqlalchemy",
|
|
40
|
+
"psycopg2-binary",
|
|
41
|
+
"pytest-cov"
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/PnX-SI/Utils-Flask-SQLAlchemy"
|
|
46
|
+
|
|
47
|
+
[project.entry-points.alembic]
|
|
48
|
+
migrations = "utils_flask_sqla.migrations:versions"
|
|
49
|
+
|
|
50
|
+
[project.entry-points."pytest11"]
|
|
51
|
+
sqla = "utils_flask_sqla.tests.plugin"
|
|
52
|
+
|
|
53
|
+
[project.entry-points."flask.commands"]
|
|
54
|
+
db = "utils_flask_sqla.commands:db_cli"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools]
|
|
57
|
+
package-dir = { "" = "src" }
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["src"]
|
|
61
|
+
|
|
62
|
+
[tool.setuptools.dynamic]
|
|
63
|
+
version = { file = "VERSION" }
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
minversion = "6.0"
|
|
67
|
+
testpaths = ["src"]
|
|
68
|
+
markers = [
|
|
69
|
+
"postgresql: tests requiring a running PostgreSQL database (see TEST_PG_DATABASE_URI env var)",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
omit = ["*/tests/*", "*/migrations/*"]
|
|
74
|
+
|
|
75
|
+
[tool.black]
|
|
76
|
+
line-length = 99
|
{utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/commands.py
RENAMED
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import click
|
|
7
7
|
import flask_migrate
|
|
8
8
|
from flask import current_app
|
|
9
|
+
import sqlalchemy as sa
|
|
9
10
|
from alembic.migration import MigrationContext
|
|
10
11
|
from alembic.context import EnvironmentContext
|
|
11
12
|
from alembic.script import ScriptDirectory
|
|
@@ -52,10 +53,10 @@ def box_drowing(up, down, left, right, bold=True):
|
|
|
52
53
|
@click.option("--json", "json_output", is_flag=True, help="Output commands results as JSON.")
|
|
53
54
|
@with_appcontext
|
|
54
55
|
def exec(command, commit, json_output):
|
|
55
|
-
db = current_app.extensions["sqlalchemy"]
|
|
56
|
+
db = current_app.extensions["sqlalchemy"]
|
|
56
57
|
results = []
|
|
57
58
|
for cmd in command:
|
|
58
|
-
results.append(db.session.execute(cmd))
|
|
59
|
+
results.append(db.session.execute(sa.text(cmd)))
|
|
59
60
|
if commit:
|
|
60
61
|
db.session.commit()
|
|
61
62
|
if json_output:
|
|
@@ -86,7 +87,7 @@ def exec(command, commit, json_output):
|
|
|
86
87
|
@with_appcontext
|
|
87
88
|
def autoupgrade(directory, sql, tag, x_arg):
|
|
88
89
|
"""Upgrade all branches to head."""
|
|
89
|
-
db = current_app.extensions["sqlalchemy"]
|
|
90
|
+
db = current_app.extensions["sqlalchemy"]
|
|
90
91
|
migrate = current_app.extensions["migrate"].migrate
|
|
91
92
|
config = migrate.get_config(directory, x_arg)
|
|
92
93
|
script = ScriptDirectory.from_config(config)
|
|
@@ -117,7 +118,8 @@ def autoupgrade(directory, sql, tag, x_arg):
|
|
|
117
118
|
@with_appcontext
|
|
118
119
|
def status(directory, x_arg, show_dependencies, branches):
|
|
119
120
|
"""Show all revisions sorted by branches."""
|
|
120
|
-
db = current_app.extensions["sqlalchemy"]
|
|
121
|
+
db = current_app.extensions["sqlalchemy"]
|
|
122
|
+
|
|
121
123
|
migrate = current_app.extensions["migrate"].migrate
|
|
122
124
|
|
|
123
125
|
config = migrate.get_config(directory, x_arg)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers to build accent-insensitive PostgreSQL full text searches.
|
|
3
|
+
|
|
4
|
+
They rely on the PostgreSQL ``unaccent`` extension (``CREATE EXTENSION unaccent``).
|
|
5
|
+
|
|
6
|
+
Examples
|
|
7
|
+
--------
|
|
8
|
+
>>> query = fts_query("anny dup")
|
|
9
|
+
>>> if query is not None:
|
|
10
|
+
... document = fts_document(User.nom_role, User.prenom_role)
|
|
11
|
+
... stmt = (
|
|
12
|
+
... select(User)
|
|
13
|
+
... .where(document.op("@@")(query))
|
|
14
|
+
... .order_by(ts_rank(document, query).desc())
|
|
15
|
+
... )
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
import sqlalchemy as sa
|
|
21
|
+
from sqlalchemy import func
|
|
22
|
+
from sqlalchemy.dialects.postgresql import REGCONFIG
|
|
23
|
+
|
|
24
|
+
__all__ = ["DEFAULT_FTS_CONFIG", "fts_document", "fts_query", "ts_rank"]
|
|
25
|
+
|
|
26
|
+
DEFAULT_FTS_CONFIG = "french"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _regconfig(config):
|
|
30
|
+
return sa.cast(sa.literal(config), REGCONFIG)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fts_document(*columns, config=DEFAULT_FTS_CONFIG):
|
|
34
|
+
"""
|
|
35
|
+
Build an accent-insensitive ``tsvector`` from text columns.
|
|
36
|
+
|
|
37
|
+
Punctuation separates words.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
*columns : sqlalchemy.sql.ColumnElement
|
|
42
|
+
Columns (or text expressions) concatenated into one document. NULLs are ignored.
|
|
43
|
+
config : str, default "french"
|
|
44
|
+
Name of the PostgreSQL text search configuration.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
sqlalchemy.sql.ColumnElement
|
|
49
|
+
The ``tsvector`` expression.
|
|
50
|
+
"""
|
|
51
|
+
# punctuation is replaced by spaces: the PostgreSQL parser would read "CA-2020" as the words
|
|
52
|
+
# "ca" and "-2020" (a negative number), which a search for "2020" would not find
|
|
53
|
+
text = func.regexp_replace(
|
|
54
|
+
func.unaccent(func.concat_ws(" ", *columns)), r"[^[:alnum:]]+", " ", "g"
|
|
55
|
+
)
|
|
56
|
+
return func.to_tsvector(_regconfig(config), text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fts_query(search, *, prefix=True, config=DEFAULT_FTS_CONFIG):
|
|
60
|
+
"""
|
|
61
|
+
Build an accent-insensitive ``tsquery`` matching all the words of a text.
|
|
62
|
+
|
|
63
|
+
Only the words (letters and digits) of `search` are kept, so it is safe to give it as typed by
|
|
64
|
+
a user. Punctuation, such as the dashes of "CA-2020" or of an UUID, separates words, like in
|
|
65
|
+
`fts_document`.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
search : str or None
|
|
70
|
+
Text to search.
|
|
71
|
+
prefix : bool, default True
|
|
72
|
+
Match the last word as a prefix, so that results follow what is being typed: the other
|
|
73
|
+
words are complete, otherwise "ca-1" would match any text with a word beginning with
|
|
74
|
+
"ca" ("carte") and another one beginning with "1".
|
|
75
|
+
config : str, default "french"
|
|
76
|
+
Name of the PostgreSQL text search configuration.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
sqlalchemy.sql.ColumnElement or None
|
|
81
|
+
The ``tsquery`` expression, or None if `search` holds no word.
|
|
82
|
+
"""
|
|
83
|
+
words = re.findall(r"[^\W_]+", search or "")
|
|
84
|
+
if not words:
|
|
85
|
+
return None
|
|
86
|
+
if prefix:
|
|
87
|
+
words[-1] += ":*"
|
|
88
|
+
return func.to_tsquery(_regconfig(config), func.unaccent(" & ".join(words)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ts_rank(document, query):
|
|
92
|
+
"""
|
|
93
|
+
Compute the relevance of a document for a query.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
document : sqlalchemy.sql.ColumnElement
|
|
98
|
+
The ``tsvector``, see `fts_document`.
|
|
99
|
+
query : sqlalchemy.sql.ColumnElement
|
|
100
|
+
The ``tsquery``, see `fts_query`.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
sqlalchemy.sql.ColumnElement
|
|
105
|
+
The rank expression, the higher the more relevant.
|
|
106
|
+
"""
|
|
107
|
+
return func.ts_rank(document, query)
|
{utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/generic.py
RENAMED
|
@@ -107,10 +107,9 @@ class GenericTable:
|
|
|
107
107
|
|
|
108
108
|
try:
|
|
109
109
|
with db.engine.connect() as conn:
|
|
110
|
-
metadata = sa.MetaData(bind=conn)
|
|
111
110
|
self.tableDef = sa.Table(
|
|
112
111
|
tableName,
|
|
113
|
-
metadata,
|
|
112
|
+
db.metadata,
|
|
114
113
|
schema=schemaName,
|
|
115
114
|
autoload_with=conn,
|
|
116
115
|
)
|
|
@@ -279,7 +278,9 @@ class GenericQuery:
|
|
|
279
278
|
nb_result_without_filter = q.count()
|
|
280
279
|
|
|
281
280
|
q = self.raw_query(process_filter=True)
|
|
282
|
-
total_filtered =
|
|
281
|
+
total_filtered = (
|
|
282
|
+
q.limit(None).offset(None).count() if self.filters else nb_result_without_filter
|
|
283
|
+
)
|
|
283
284
|
|
|
284
285
|
data = q.all()
|
|
285
286
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""add functions to fetch dependent view and recreate them
|
|
2
|
+
|
|
3
|
+
Revision ID: 1d09a9b67970
|
|
4
|
+
Revises: ba207b468e31
|
|
5
|
+
Create Date: 2026-08-12 11:12:11.614522
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from alembic import op
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
|
|
12
|
+
# revision identifiers, used by Alembic.
|
|
13
|
+
revision = "1d09a9b67970"
|
|
14
|
+
down_revision = "ba207b468e31"
|
|
15
|
+
branch_labels = None
|
|
16
|
+
depends_on = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade():
|
|
20
|
+
op.execute(sa.text("""
|
|
21
|
+
|
|
22
|
+
-- 1. Recursively find all views/matviews that (transitively) depend on
|
|
23
|
+
-- a given table or view, ordered deepest-dependent first.
|
|
24
|
+
|
|
25
|
+
CREATE OR REPLACE FUNCTION public.pg_view_dependency_tree(p_schema text, p_relname text)
|
|
26
|
+
RETURNS TABLE(view_schema text, view_name text, view_kind text, depth int)
|
|
27
|
+
LANGUAGE sql
|
|
28
|
+
AS $$
|
|
29
|
+
WITH RECURSIVE deps AS (
|
|
30
|
+
SELECT
|
|
31
|
+
dependent_ns.nspname::text AS view_schema,
|
|
32
|
+
dependent_view.relname::text AS view_name,
|
|
33
|
+
dependent_view.relkind::text AS view_kind,
|
|
34
|
+
1 AS depth
|
|
35
|
+
FROM pg_depend
|
|
36
|
+
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
|
|
37
|
+
JOIN pg_class dependent_view ON pg_rewrite.ev_class = dependent_view.oid
|
|
38
|
+
JOIN pg_class source_table ON pg_depend.refobjid = source_table.oid
|
|
39
|
+
JOIN pg_namespace dependent_ns ON dependent_view.relnamespace = dependent_ns.oid
|
|
40
|
+
JOIN pg_namespace source_ns ON source_table.relnamespace = source_ns.oid
|
|
41
|
+
WHERE source_table.relname = p_relname
|
|
42
|
+
AND source_ns.nspname = p_schema
|
|
43
|
+
AND dependent_view.relkind IN ('v', 'm')
|
|
44
|
+
AND dependent_view.oid <> source_table.oid
|
|
45
|
+
|
|
46
|
+
UNION
|
|
47
|
+
|
|
48
|
+
SELECT
|
|
49
|
+
dependent_ns.nspname::text,
|
|
50
|
+
dependent_view.relname::text,
|
|
51
|
+
dependent_view.relkind::text,
|
|
52
|
+
deps.depth + 1
|
|
53
|
+
FROM deps
|
|
54
|
+
JOIN pg_namespace source_ns ON source_ns.nspname = deps.view_schema
|
|
55
|
+
JOIN pg_class source_table
|
|
56
|
+
ON source_table.relname = deps.view_name
|
|
57
|
+
AND source_table.relnamespace = source_ns.oid
|
|
58
|
+
JOIN pg_depend ON pg_depend.refobjid = source_table.oid
|
|
59
|
+
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
|
|
60
|
+
JOIN pg_class dependent_view ON pg_rewrite.ev_class = dependent_view.oid
|
|
61
|
+
JOIN pg_namespace dependent_ns ON dependent_view.relnamespace = dependent_ns.oid
|
|
62
|
+
WHERE dependent_view.relkind IN ('v', 'm')
|
|
63
|
+
AND dependent_view.oid <> source_table.oid
|
|
64
|
+
)
|
|
65
|
+
SELECT view_schema, view_name, view_kind, MAX(depth) AS depth
|
|
66
|
+
FROM deps
|
|
67
|
+
GROUP BY view_schema, view_name, view_kind
|
|
68
|
+
ORDER BY depth DESC; -- most-dependent first (correct drop order)
|
|
69
|
+
$$;
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
-- 2. Capture full definitions (+ owner, + indexes for matviews) as jsonb,
|
|
73
|
+
-- in drop order.
|
|
74
|
+
CREATE OR REPLACE FUNCTION public.pg_capture_dependent_views(p_schema text, p_relname text)
|
|
75
|
+
RETURNS jsonb
|
|
76
|
+
LANGUAGE plpgsql
|
|
77
|
+
AS $$
|
|
78
|
+
DECLARE
|
|
79
|
+
result jsonb := '[]'::jsonb;
|
|
80
|
+
rec record;
|
|
81
|
+
v_def text;
|
|
82
|
+
v_owner text;
|
|
83
|
+
v_indexes jsonb;
|
|
84
|
+
BEGIN
|
|
85
|
+
FOR rec IN
|
|
86
|
+
SELECT * FROM public.pg_view_dependency_tree(p_schema, p_relname)
|
|
87
|
+
LOOP
|
|
88
|
+
SELECT pg_get_viewdef(format('%I.%I', rec.view_schema, rec.view_name)::regclass, true)
|
|
89
|
+
INTO v_def;
|
|
90
|
+
|
|
91
|
+
SELECT pg_get_userbyid(c.relowner) INTO v_owner
|
|
92
|
+
FROM pg_class c
|
|
93
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
94
|
+
WHERE c.relname = rec.view_name AND n.nspname = rec.view_schema;
|
|
95
|
+
|
|
96
|
+
v_indexes := '[]'::jsonb;
|
|
97
|
+
IF rec.view_kind = 'm' THEN
|
|
98
|
+
SELECT coalesce(jsonb_agg(indexdef), '[]'::jsonb) INTO v_indexes
|
|
99
|
+
FROM pg_indexes
|
|
100
|
+
WHERE schemaname = rec.view_schema AND tablename = rec.view_name;
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
result := result || jsonb_build_object(
|
|
104
|
+
'schema', rec.view_schema,
|
|
105
|
+
'name', rec.view_name,
|
|
106
|
+
'kind', rec.view_kind,
|
|
107
|
+
'definition', v_def,
|
|
108
|
+
'owner', v_owner,
|
|
109
|
+
'indexes', v_indexes
|
|
110
|
+
);
|
|
111
|
+
END LOOP;
|
|
112
|
+
RETURN result;
|
|
113
|
+
END;
|
|
114
|
+
$$;
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
-- 3. Drop the captured views (dependents first, order already correct
|
|
118
|
+
-- in the jsonb array). CASCADE is a safety net, not the primary
|
|
119
|
+
-- mechanism, since we already dropped dependents-first.
|
|
120
|
+
CREATE OR REPLACE FUNCTION public.pg_drop_dependent_views(p_views jsonb)
|
|
121
|
+
RETURNS void
|
|
122
|
+
LANGUAGE plpgsql
|
|
123
|
+
AS $$
|
|
124
|
+
DECLARE
|
|
125
|
+
rec jsonb;
|
|
126
|
+
obj_type text;
|
|
127
|
+
BEGIN
|
|
128
|
+
FOR rec IN SELECT * FROM jsonb_array_elements(p_views)
|
|
129
|
+
LOOP
|
|
130
|
+
obj_type := CASE WHEN rec->>'kind' = 'm' THEN 'MATERIALIZED VIEW' ELSE 'VIEW' END;
|
|
131
|
+
EXECUTE format('DROP %s IF EXISTS %I.%I CASCADE', obj_type, rec->>'schema', rec->>'name');
|
|
132
|
+
END LOOP;
|
|
133
|
+
END;
|
|
134
|
+
$$;
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
-- 4. Recreate the captured views in reverse order (dependencies before
|
|
138
|
+
-- dependents), reapply owner, indexes, and refresh matviews.
|
|
139
|
+
CREATE OR REPLACE FUNCTION public.pg_recreate_dependent_views(p_views jsonb)
|
|
140
|
+
RETURNS void
|
|
141
|
+
LANGUAGE plpgsql
|
|
142
|
+
AS $$
|
|
143
|
+
DECLARE
|
|
144
|
+
rec jsonb;
|
|
145
|
+
obj_type text;
|
|
146
|
+
idx text;
|
|
147
|
+
n int;
|
|
148
|
+
i int;
|
|
149
|
+
BEGIN
|
|
150
|
+
n := jsonb_array_length(p_views);
|
|
151
|
+
FOR i IN REVERSE (n - 1)..0 LOOP
|
|
152
|
+
rec := p_views -> i;
|
|
153
|
+
obj_type := CASE WHEN rec->>'kind' = 'm' THEN 'MATERIALIZED VIEW' ELSE 'VIEW' END;
|
|
154
|
+
|
|
155
|
+
EXECUTE format('CREATE %s %I.%I AS %s',
|
|
156
|
+
obj_type, rec->>'schema', rec->>'name', rec->>'definition');
|
|
157
|
+
|
|
158
|
+
IF rec->>'owner' IS NOT NULL THEN
|
|
159
|
+
EXECUTE format('ALTER %s %I.%I OWNER TO %I',
|
|
160
|
+
obj_type, rec->>'schema', rec->>'name', rec->>'owner');
|
|
161
|
+
END IF;
|
|
162
|
+
|
|
163
|
+
IF jsonb_array_length(rec->'indexes') > 0 THEN
|
|
164
|
+
FOR idx IN SELECT jsonb_array_elements_text(rec->'indexes')
|
|
165
|
+
LOOP
|
|
166
|
+
EXECUTE idx;
|
|
167
|
+
END LOOP;
|
|
168
|
+
END IF;
|
|
169
|
+
|
|
170
|
+
IF rec->>'kind' = 'm' THEN
|
|
171
|
+
EXECUTE format('REFRESH MATERIALIZED VIEW %I.%I', rec->>'schema', rec->>'name');
|
|
172
|
+
END IF;
|
|
173
|
+
END LOOP;
|
|
174
|
+
END;
|
|
175
|
+
$$;
|
|
176
|
+
"""))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def downgrade():
|
|
180
|
+
op.execute(sa.text("""
|
|
181
|
+
DROP FUNCTION IF EXISTS public.pg_recreate_dependent_views(jsonb);
|
|
182
|
+
DROP FUNCTION IF EXISTS public.pg_drop_dependent_views(jsonb);
|
|
183
|
+
DROP FUNCTION IF EXISTS public.pg_capture_dependent_views(text, text);
|
|
184
|
+
DROP FUNCTION IF EXISTS public.pg_view_dependency_tree(text, text);
|
|
185
|
+
"""))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import (
|
|
5
|
+
inspect as sa_inspect,
|
|
6
|
+
func,
|
|
7
|
+
exists,
|
|
8
|
+
select,
|
|
9
|
+
table as sa_table,
|
|
10
|
+
column as sa_column,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
CSV_FIELDNAMES = [
|
|
14
|
+
"ref_table",
|
|
15
|
+
"table_name",
|
|
16
|
+
"schema",
|
|
17
|
+
"fk_column",
|
|
18
|
+
"fk_value",
|
|
19
|
+
"nb_affected_lines",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_referencing_tables(table_name, db, schema="", exclude_tables=[]):
|
|
24
|
+
"""Trouve toutes les tables qui ont des FK pointant vers table_name,
|
|
25
|
+
en excluant les tables listées dans exclude_tables (même schéma)."""
|
|
26
|
+
exclude_tables = set(exclude_tables or [])
|
|
27
|
+
inspector = sa_inspect(db.session.connection())
|
|
28
|
+
referencing_tables = []
|
|
29
|
+
|
|
30
|
+
for other_schema in inspector.get_schema_names():
|
|
31
|
+
if other_schema in ("pg_catalog", "information_schema", "pg_toast"):
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
for other_table in inspector.get_table_names(schema=other_schema):
|
|
35
|
+
if other_table != table_name and other_table in exclude_tables:
|
|
36
|
+
continue
|
|
37
|
+
for fk in inspector.get_foreign_keys(other_table, schema=other_schema):
|
|
38
|
+
if fk["referred_table"] == f"{schema}.{table_name}" or (
|
|
39
|
+
fk["referred_table"] == table_name
|
|
40
|
+
and (fk.get("referred_schema") or schema) == schema
|
|
41
|
+
):
|
|
42
|
+
referencing_tables.append(
|
|
43
|
+
{
|
|
44
|
+
"schema": other_schema,
|
|
45
|
+
"table": other_table,
|
|
46
|
+
"fk_column": fk["constrained_columns"][0],
|
|
47
|
+
"ref_column": fk["referred_columns"][0],
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"Impossible d'inspecter le schéma {other_schema}: {e}")
|
|
52
|
+
|
|
53
|
+
return referencing_tables
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def collect_orphan_rows(ref_table, new_ref_table, pk_col, db, schema="", exclude_tables=None):
|
|
57
|
+
"""
|
|
58
|
+
Retourne la liste des lignes orphelines pour une table du référentiel :
|
|
59
|
+
valeurs de pk_col présentes dans les tables référençantes mais absentes de new_ref_table.
|
|
60
|
+
"""
|
|
61
|
+
referencing = get_referencing_tables(
|
|
62
|
+
ref_table, db, schema=schema, exclude_tables=exclude_tables
|
|
63
|
+
)
|
|
64
|
+
rows = []
|
|
65
|
+
for ref in referencing:
|
|
66
|
+
fk_col = ref["fk_column"]
|
|
67
|
+
src = sa_table(ref["table"], sa_column(fk_col), schema=ref["schema"])
|
|
68
|
+
ref_t = sa_table(new_ref_table, sa_column(pk_col), schema=schema)
|
|
69
|
+
subq = select(1).select_from(ref_t).where(ref_t.c[pk_col] == src.c[fk_col])
|
|
70
|
+
stmt = (
|
|
71
|
+
select(src.c[fk_col], func.count().label("nb_lines"))
|
|
72
|
+
.where(src.c[fk_col].isnot(None))
|
|
73
|
+
.where(~exists(subq))
|
|
74
|
+
.group_by(src.c[fk_col])
|
|
75
|
+
.order_by(src.c[fk_col])
|
|
76
|
+
)
|
|
77
|
+
for fk_value, nb_lines in db.session.execute(stmt).fetchall():
|
|
78
|
+
rows.append(
|
|
79
|
+
{
|
|
80
|
+
"ref_table": ref_table,
|
|
81
|
+
"table_name": ref["table"],
|
|
82
|
+
"schema": ref["schema"],
|
|
83
|
+
"fk_column": fk_col,
|
|
84
|
+
"fk_value": fk_value,
|
|
85
|
+
"nb_affected_lines": nb_lines,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
return rows
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def export_orphans_to_csv(rows, output_path):
|
|
92
|
+
"""Écrit la liste de lignes orphelines dans un CSV."""
|
|
93
|
+
if not rows:
|
|
94
|
+
return 0
|
|
95
|
+
dirname = os.path.dirname(output_path)
|
|
96
|
+
if dirname:
|
|
97
|
+
os.makedirs(dirname, exist_ok=True)
|
|
98
|
+
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
|
99
|
+
writer = csv.DictWriter(f, fieldnames=CSV_FIELDNAMES)
|
|
100
|
+
writer.writeheader()
|
|
101
|
+
writer.writerows(rows)
|
|
102
|
+
return len(rows)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from sqlalchemy import text
|
|
3
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
4
|
+
from sqlalchemy import bindparam
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@contextmanager
|
|
8
|
+
def alter_table_with_dependent_views(conn, schema, table_name):
|
|
9
|
+
"""
|
|
10
|
+
Python wrapper that allows to modify a table/view with dependent views (and their dependant views).
|
|
11
|
+
|
|
12
|
+
**Example**
|
|
13
|
+
|
|
14
|
+
>>> with alter_table_with_dependent_views(conn,"gn_synthese","synthese"):
|
|
15
|
+
>>> op.execute("ALTER gn_synthese.synthese DROP COLUMN id_individual")
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
conn: any
|
|
20
|
+
SQLAlchemy connection to your database
|
|
21
|
+
schema: str
|
|
22
|
+
name of the schema of the table
|
|
23
|
+
table_name: str
|
|
24
|
+
name of the table in your schema
|
|
25
|
+
|
|
26
|
+
Notes
|
|
27
|
+
-----
|
|
28
|
+
This function relies on the existence of sql functions (`public.pg_capture_dependent_views`,
|
|
29
|
+
`public.pg_drop_dependent_views`, `public.pg_recreate_dependent_views`) in your database !
|
|
30
|
+
Make sure the revision 1d09a9b67970 is applied on your database !
|
|
31
|
+
In an Alembic revision, you can enforce this by adding `depends_on = ("1d09a9b67970",)`
|
|
32
|
+
"""
|
|
33
|
+
captured = conn.execute(
|
|
34
|
+
text("SELECT public.pg_capture_dependent_views(:s, :t)"),
|
|
35
|
+
{"s": schema, "t": table_name},
|
|
36
|
+
).scalar()
|
|
37
|
+
|
|
38
|
+
conn.execute(
|
|
39
|
+
text("SELECT public.pg_drop_dependent_views(:v)").bindparams(bindparam("v", type_=JSONB)),
|
|
40
|
+
{"v": captured},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
finally:
|
|
46
|
+
conn.execute(
|
|
47
|
+
text("SELECT public.pg_recreate_dependent_views(:v)").bindparams(
|
|
48
|
+
bindparam("v", type_=JSONB)
|
|
49
|
+
),
|
|
50
|
+
{"v": captured},
|
|
51
|
+
)
|
{utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/schema.py
RENAMED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from marshmallow.fields import Nested
|
|
2
2
|
from marshmallow_sqlalchemy.fields import RelatedList, Related
|
|
3
|
-
|
|
4
|
-
# from flask_marshmallow.fields import RelatedList
|
|
3
|
+
from sqlalchemy.orm import ColumnProperty
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
class SmartRelationshipsMixin:
|
|
@@ -54,12 +53,12 @@ class SmartRelationshipsMixin:
|
|
|
54
53
|
nested_fields.add(name)
|
|
55
54
|
elif field.metadata.get("exclude", False):
|
|
56
55
|
excluded_fields.add(name)
|
|
57
|
-
elif (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
elif hasattr(self.opts, "model") and hasattr(self.opts.model.__mapper__.attrs, name):
|
|
57
|
+
column_prop = getattr(self.opts.model.__mapper__.attrs, name)
|
|
58
|
+
if isinstance(column_prop, ColumnProperty) and column_prop.deferred is True:
|
|
59
|
+
excluded_fields.add(name)
|
|
60
|
+
else:
|
|
61
|
+
included_fields.add(name)
|
|
63
62
|
else:
|
|
64
63
|
included_fields.add(name)
|
|
65
64
|
|
{utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/serializers.py
RENAMED
|
@@ -11,7 +11,8 @@ from uuid import UUID
|
|
|
11
11
|
|
|
12
12
|
from sqlalchemy.orm import ColumnProperty
|
|
13
13
|
from sqlalchemy import inspect
|
|
14
|
-
from sqlalchemy.ext.hybrid import hybrid_property
|
|
14
|
+
from sqlalchemy.ext.hybrid import hybrid_property
|
|
15
|
+
from sqlalchemy.ext.hybrid import HybridExtensionType
|
|
15
16
|
from sqlalchemy.types import DateTime, Date, Time
|
|
16
17
|
from sqlalchemy.dialects.postgresql.base import UUID
|
|
17
18
|
|
|
@@ -28,6 +29,8 @@ SERIALIZERS = {
|
|
|
28
29
|
"numeric": lambda x: str(x) if x else None,
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
HYBRID_PROPERTY = HybridExtensionType.HYBRID_PROPERTY
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
def get_serializer(col):
|
|
33
36
|
if isinstance(col, ColumnProperty):
|