@@ -47,6 +47,23 @@ class TableGroupStats(EntityMinimal):
4747 data_point_ct : int
4848
4949
50+ @dataclass
51+ class TableGroupListItem (EntityMinimal ):
52+ id : UUID
53+ table_groups_name : str
54+ table_group_schema : str
55+ project_code : str
56+ connection_name : str | None
57+ table_count : int
58+ column_count : int | None
59+ row_count : int | None
60+ last_profiled_date : datetime | None
61+ last_tested_date : datetime | None
62+ profiling_score : float | None
63+ testing_score : float | None
64+ quality_score : float | None
65+
66+
5067@dataclass
5168class TableGroupSummary (EntityMinimal ):
5269 id : UUID
@@ -149,6 +166,20 @@ class TableGroup(Entity):
149166 dq_score_testing ,
150167 )
151168
169+ @property
170+ def quality_score (self ) -> float :
171+ """Overall quality score per ``utils.score``: profiling * testing when both
172+ exist; the non-null one when only one ran; ``0.0`` when neither has run.
173+
174+ Mirrors what Project Dashboard, data_catalog, and inventory_service display.
175+ Render through ``utils.friendly_score`` to get the user-facing percentage form
176+ (``"95.0"`` rather than ``"0.95"``). The list-side equivalent is computed in
177+ SQL inside ``_list_with_activity`` — keep both in sync if the formula changes.
178+ """
179+ from testgen .utils import score
180+
181+ return score (self .dq_score_profiling , self .dq_score_testing )
182+
152183 @classmethod
153184 def get_minimal (cls , id_ : str | UUID ) -> TableGroupMinimal | None :
154185 result = cls ._get_columns (id_ , cls ._minimal_columns )
@@ -384,6 +415,103 @@ def select_summary(
384415 total = items [0 ].total_count if items else 0
385416 return items , total
386417
418+ @classmethod
419+ def list_for_project (
420+ cls , project_code : str , * , page : int = 1 , limit : int = 20
421+ ) -> tuple [list [TableGroupListItem ], int ]:
422+ """Config-focused paginated listing for a project, with table count and activity timestamps."""
423+ return cls ._list_with_activity (
424+ scope_sql = "groups.project_code = :project_code" ,
425+ scope_params = {"project_code" : project_code },
426+ page = page ,
427+ limit = limit ,
428+ )
429+
430+ @classmethod
431+ def list_for_connection (
432+ cls , connection_id : int , * , page : int = 1 , limit : int = 20
433+ ) -> tuple [list [TableGroupListItem ], int ]:
434+ """Config-focused paginated listing for a connection, with table count and activity timestamps."""
435+ return cls ._list_with_activity (
436+ scope_sql = "groups.connection_id = :connection_id" ,
437+ scope_params = {"connection_id" : connection_id },
438+ page = page ,
439+ limit = limit ,
440+ )
441+
442+ @classmethod
443+ def _list_with_activity (
444+ cls ,
445+ * ,
446+ scope_sql : str ,
447+ scope_params : dict ,
448+ page : int ,
449+ limit : int ,
450+ ) -> tuple [list [TableGroupListItem ], int ]:
451+ session = get_current_session ()
452+
453+ # Separate COUNT(*) query — keeps `total` correct on out-of-range pages where
454+ # the page rows are empty (a window function over zero rows would return 0).
455+ total_query = f"SELECT COUNT(*) FROM table_groups AS groups WHERE { scope_sql } ;"
456+ total = session .execute (text (total_query ), scope_params ).scalar () or 0
457+ if total == 0 :
458+ return [], 0
459+
460+ params : dict = {** scope_params , "limit" : limit , "offset" : (page - 1 ) * limit }
461+ rows_query = f"""
462+ WITH stats AS (
463+ SELECT table_groups_id,
464+ COUNT(*) AS table_count,
465+ SUM(column_ct) AS column_count,
466+ SUM(COALESCE(record_ct, approx_record_ct)) AS row_count
467+ FROM data_table_chars
468+ GROUP BY table_groups_id
469+ ),
470+ latest_profile AS (
471+ SELECT pr.table_groups_id, MAX(je.started_at) AS started_at
472+ FROM profiling_runs pr
473+ LEFT JOIN job_executions je ON je.id = pr.job_execution_id
474+ GROUP BY pr.table_groups_id
475+ ),
476+ latest_test AS (
477+ SELECT ts.table_groups_id, MAX(tr.test_starttime) AS test_starttime
478+ FROM test_runs tr
479+ JOIN test_suites ts ON ts.id = tr.test_suite_id
480+ WHERE ts.is_monitor IS NOT TRUE
481+ GROUP BY ts.table_groups_id
482+ )
483+ SELECT
484+ groups.id,
485+ groups.table_groups_name,
486+ groups.table_group_schema,
487+ groups.project_code,
488+ connections.connection_name,
489+ COALESCE(stats.table_count, 0) AS table_count,
490+ stats.column_count,
491+ stats.row_count,
492+ latest_profile.started_at AS last_profiled_date,
493+ latest_test.test_starttime AS last_tested_date,
494+ groups.dq_score_profiling AS profiling_score,
495+ groups.dq_score_testing AS testing_score,
496+ -- Mirrors utils.score: product when both exist, else the non-null one, else NULL.
497+ CASE
498+ WHEN groups.dq_score_profiling IS NOT NULL AND groups.dq_score_testing IS NOT NULL
499+ THEN groups.dq_score_profiling * groups.dq_score_testing
500+ ELSE COALESCE(groups.dq_score_profiling, groups.dq_score_testing)
501+ END AS quality_score
502+ FROM table_groups AS groups
503+ LEFT JOIN connections ON connections.connection_id = groups.connection_id
504+ LEFT JOIN stats ON stats.table_groups_id = groups.id
505+ LEFT JOIN latest_profile ON latest_profile.table_groups_id = groups.id
506+ LEFT JOIN latest_test ON latest_test.table_groups_id = groups.id
507+ WHERE { scope_sql }
508+ ORDER BY LOWER(groups.table_groups_name)
509+ LIMIT :limit OFFSET :offset;
510+ """
511+ rows = session .execute (text (rows_query ), params ).mappings ().all ()
512+ items = [TableGroupListItem (** row ) for row in rows ]
513+ return items , total
514+
387515 @classmethod
388516 def is_in_use (cls , ids : list [str ]) -> bool :
389517 test_suites = TestSuite .select_minimal_where (TestSuite .table_groups_id .in_ (ids ))
0 commit comments