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.
Files changed (55) hide show
  1. {utils_flask_sqlalchemy-0.4.5/src/utils_flask_sqlalchemy.egg-info → utils_flask_sqlalchemy-0.5.0}/PKG-INFO +8 -14
  2. utils_flask_sqlalchemy-0.5.0/VERSION +1 -0
  3. utils_flask_sqlalchemy-0.5.0/pyproject.toml +76 -0
  4. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/commands.py +6 -4
  5. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/fulltext.py +107 -0
  6. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/generic.py +4 -3
  7. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/migrations/versions/1d09a9b67970_add_functions_to_fetch_dependent_view_.py +185 -0
  8. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/referential.py +102 -0
  9. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/revision.py +51 -0
  10. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/schema.py +7 -8
  11. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/serializers.py +4 -1
  12. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/fixtures.py +10 -24
  13. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/__init__.py +0 -0
  14. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/conftest.py +88 -0
  15. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/test_fulltext.py +80 -0
  16. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/test_referential.py +112 -0
  17. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/psql_tests/test_revision.py +127 -0
  18. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/test_fulltext.py +51 -0
  19. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/test_generic.py +441 -0
  20. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_temporary_transaction.py +8 -0
  21. utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqla/tests/utils.py +56 -0
  22. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0/src/utils_flask_sqlalchemy.egg-info}/PKG-INFO +8 -14
  23. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/SOURCES.txt +11 -2
  24. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/requires.txt +3 -1
  25. utils_flask_sqlalchemy-0.4.5/VERSION +0 -1
  26. utils_flask_sqlalchemy-0.4.5/pyproject.toml +0 -13
  27. utils_flask_sqlalchemy-0.4.5/requirements.in +0 -6
  28. utils_flask_sqlalchemy-0.4.5/setup.py +0 -54
  29. utils_flask_sqlalchemy-0.4.5/src/utils_flask_sqla/tests/utils.py +0 -28
  30. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/LICENSE +0 -0
  31. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/MANIFEST.in +0 -0
  32. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/README.md +0 -0
  33. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/setup.cfg +0 -0
  34. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/__init__.py +0 -0
  35. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/db.py +0 -0
  36. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/env.py +0 -0
  37. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/errors.py +0 -0
  38. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/__init__.py +0 -0
  39. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/utils.py +0 -0
  40. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/versions/3842a6d800a0_sql_utils.py +0 -0
  41. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/migrations/versions/__init__.py +0 -0
  42. {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
  43. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/models.py +0 -0
  44. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/response.py +0 -0
  45. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/__init__.py +0 -0
  46. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/plugin.py +0 -0
  47. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_ordered.py +0 -0
  48. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_qfilter.py +0 -0
  49. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_schema.py +0 -0
  50. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/tests/test_serializers.py +0 -0
  51. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqla/utils.py +0 -0
  52. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/dependency_links.txt +0 -0
  53. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/entry_points.txt +0 -0
  54. {utils_flask_sqlalchemy-0.4.5 → utils_flask_sqlalchemy-0.5.0}/src/utils_flask_sqlalchemy.egg-info/top_level.txt +0 -0
  55. {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.4.5
3
+ Version: 0.5.0
4
4
  Summary: Python lib of tools for Flask and SQLAlchemy
5
- Home-page: https://github.com/PnX-SI/Utils-Flask-SQLAlchemy
6
- Maintainer: Parcs nationaux des Écrins et des Cévennes
7
- Maintainer-email: geonature@ecrins-parcnational.fr
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<2
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
- Dynamic: classifier
30
- Dynamic: description
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
@@ -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"].db
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"].db
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"].db
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)
@@ -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 = q.limit(None).count() if self.filters else nb_result_without_filter
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
+ )
@@ -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
- hasattr(self.opts, "model")
59
- and hasattr(self.opts.model.__mapper__.column_attrs, name)
60
- and getattr(self.opts.model.__mapper__.column_attrs, name).deferred
61
- ):
62
- excluded_fields.add(name)
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
 
@@ -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, 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):