pydpm_xl 0.1.39rc31__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. py_dpm/__init__.py +1 -1
  2. py_dpm/api/__init__.py +58 -189
  3. py_dpm/api/dpm/__init__.py +20 -0
  4. py_dpm/api/{data_dictionary.py → dpm/data_dictionary.py} +903 -984
  5. py_dpm/api/dpm/explorer.py +236 -0
  6. py_dpm/api/dpm/hierarchical_queries.py +142 -0
  7. py_dpm/api/{migration.py → dpm/migration.py} +16 -19
  8. py_dpm/api/{operation_scopes.py → dpm/operation_scopes.py} +319 -267
  9. py_dpm/api/dpm_xl/__init__.py +25 -0
  10. py_dpm/api/{ast_generator.py → dpm_xl/ast_generator.py} +3 -3
  11. py_dpm/api/{complete_ast.py → dpm_xl/complete_ast.py} +192 -168
  12. py_dpm/api/dpm_xl/semantic.py +354 -0
  13. py_dpm/api/{syntax.py → dpm_xl/syntax.py} +6 -5
  14. py_dpm/api/explorer.py +4 -0
  15. py_dpm/api/semantic.py +30 -306
  16. py_dpm/cli/__init__.py +9 -0
  17. py_dpm/{client.py → cli/main.py} +8 -8
  18. py_dpm/dpm/__init__.py +11 -0
  19. py_dpm/{models.py → dpm/models.py} +112 -88
  20. py_dpm/dpm/queries/base.py +100 -0
  21. py_dpm/dpm/queries/basic_objects.py +33 -0
  22. py_dpm/dpm/queries/explorer_queries.py +352 -0
  23. py_dpm/dpm/queries/filters.py +139 -0
  24. py_dpm/dpm/queries/glossary.py +45 -0
  25. py_dpm/dpm/queries/hierarchical_queries.py +838 -0
  26. py_dpm/dpm/queries/tables.py +133 -0
  27. py_dpm/dpm/utils.py +356 -0
  28. py_dpm/dpm_xl/__init__.py +8 -0
  29. py_dpm/dpm_xl/ast/__init__.py +14 -0
  30. py_dpm/{AST/ASTConstructor.py → dpm_xl/ast/constructor.py} +6 -6
  31. py_dpm/{AST/MLGeneration.py → dpm_xl/ast/ml_generation.py} +137 -87
  32. py_dpm/{AST/ModuleAnalyzer.py → dpm_xl/ast/module_analyzer.py} +7 -7
  33. py_dpm/{AST/ModuleDependencies.py → dpm_xl/ast/module_dependencies.py} +56 -41
  34. py_dpm/{AST/ASTObjects.py → dpm_xl/ast/nodes.py} +1 -1
  35. py_dpm/{AST/check_operands.py → dpm_xl/ast/operands.py} +16 -13
  36. py_dpm/{AST/ASTTemplate.py → dpm_xl/ast/template.py} +2 -2
  37. py_dpm/{AST/WhereClauseChecker.py → dpm_xl/ast/where_clause.py} +2 -2
  38. py_dpm/dpm_xl/grammar/__init__.py +18 -0
  39. py_dpm/dpm_xl/operators/__init__.py +19 -0
  40. py_dpm/{Operators/AggregateOperators.py → dpm_xl/operators/aggregate.py} +7 -7
  41. py_dpm/{Operators/NumericOperators.py → dpm_xl/operators/arithmetic.py} +6 -6
  42. py_dpm/{Operators/Operator.py → dpm_xl/operators/base.py} +5 -5
  43. py_dpm/{Operators/BooleanOperators.py → dpm_xl/operators/boolean.py} +5 -5
  44. py_dpm/{Operators/ClauseOperators.py → dpm_xl/operators/clause.py} +8 -8
  45. py_dpm/{Operators/ComparisonOperators.py → dpm_xl/operators/comparison.py} +5 -5
  46. py_dpm/{Operators/ConditionalOperators.py → dpm_xl/operators/conditional.py} +7 -7
  47. py_dpm/{Operators/StringOperators.py → dpm_xl/operators/string.py} +5 -5
  48. py_dpm/{Operators/TimeOperators.py → dpm_xl/operators/time.py} +6 -6
  49. py_dpm/{semantics/SemanticAnalyzer.py → dpm_xl/semantic_analyzer.py} +168 -68
  50. py_dpm/{semantics/Symbols.py → dpm_xl/symbols.py} +3 -3
  51. py_dpm/dpm_xl/types/__init__.py +13 -0
  52. py_dpm/{DataTypes/TypePromotion.py → dpm_xl/types/promotion.py} +2 -2
  53. py_dpm/{DataTypes/ScalarTypes.py → dpm_xl/types/scalar.py} +2 -2
  54. py_dpm/dpm_xl/utils/__init__.py +14 -0
  55. py_dpm/{data_handlers.py → dpm_xl/utils/data_handlers.py} +2 -2
  56. py_dpm/{Utils → dpm_xl/utils}/operands_mapping.py +1 -1
  57. py_dpm/{Utils → dpm_xl/utils}/operator_mapping.py +8 -8
  58. py_dpm/{OperationScopes/OperationScopeService.py → dpm_xl/utils/scopes_calculator.py} +148 -58
  59. py_dpm/{Utils/ast_serialization.py → dpm_xl/utils/serialization.py} +2 -2
  60. py_dpm/dpm_xl/validation/__init__.py +12 -0
  61. py_dpm/{Utils/ValidationsGenerationUtils.py → dpm_xl/validation/generation_utils.py} +2 -3
  62. py_dpm/{ValidationsGeneration/PropertiesConstraintsProcessor.py → dpm_xl/validation/property_constraints.py} +56 -21
  63. py_dpm/{ValidationsGeneration/auxiliary_functions.py → dpm_xl/validation/utils.py} +2 -2
  64. py_dpm/{ValidationsGeneration/VariantsProcessor.py → dpm_xl/validation/variants.py} +149 -55
  65. py_dpm/exceptions/__init__.py +23 -0
  66. py_dpm/{Exceptions → exceptions}/exceptions.py +7 -2
  67. pydpm_xl-0.2.0.dist-info/METADATA +278 -0
  68. pydpm_xl-0.2.0.dist-info/RECORD +88 -0
  69. pydpm_xl-0.2.0.dist-info/entry_points.txt +2 -0
  70. py_dpm/Exceptions/__init__.py +0 -0
  71. py_dpm/OperationScopes/__init__.py +0 -0
  72. py_dpm/Operators/__init__.py +0 -0
  73. py_dpm/Utils/__init__.py +0 -0
  74. py_dpm/Utils/utils.py +0 -2
  75. py_dpm/ValidationsGeneration/Utils.py +0 -364
  76. py_dpm/ValidationsGeneration/__init__.py +0 -0
  77. py_dpm/api/data_dictionary_validation.py +0 -614
  78. py_dpm/db_utils.py +0 -221
  79. py_dpm/grammar/__init__.py +0 -0
  80. py_dpm/grammar/dist/__init__.py +0 -0
  81. py_dpm/grammar/dpm_xlLexer.g4 +0 -437
  82. py_dpm/grammar/dpm_xlParser.g4 +0 -263
  83. py_dpm/semantics/DAG/DAGAnalyzer.py +0 -158
  84. py_dpm/semantics/DAG/__init__.py +0 -0
  85. py_dpm/semantics/__init__.py +0 -0
  86. py_dpm/views/data_types.sql +0 -12
  87. py_dpm/views/datapoints.sql +0 -65
  88. py_dpm/views/hierarchy_operand_reference.sql +0 -11
  89. py_dpm/views/hierarchy_preconditions.sql +0 -13
  90. py_dpm/views/hierarchy_variables.sql +0 -26
  91. py_dpm/views/hierarchy_variables_context.sql +0 -14
  92. py_dpm/views/key_components.sql +0 -18
  93. py_dpm/views/module_from_table.sql +0 -11
  94. py_dpm/views/open_keys.sql +0 -13
  95. py_dpm/views/operation_info.sql +0 -27
  96. py_dpm/views/operation_list.sql +0 -18
  97. py_dpm/views/operations_versions_from_module_version.sql +0 -30
  98. py_dpm/views/precondition_info.sql +0 -17
  99. py_dpm/views/report_type_operand_reference_info.sql +0 -18
  100. py_dpm/views/subcategory_info.sql +0 -17
  101. py_dpm/views/table_info.sql +0 -19
  102. pydpm_xl-0.1.39rc31.dist-info/METADATA +0 -53
  103. pydpm_xl-0.1.39rc31.dist-info/RECORD +0 -96
  104. pydpm_xl-0.1.39rc31.dist-info/entry_points.txt +0 -2
  105. /py_dpm/{AST → cli/commands}/__init__.py +0 -0
  106. /py_dpm/{migration.py → dpm/migration.py} +0 -0
  107. /py_dpm/{AST/ASTVisitor.py → dpm_xl/ast/visitor.py} +0 -0
  108. /py_dpm/{DataTypes → dpm_xl/grammar/generated}/__init__.py +0 -0
  109. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlLexer.interp +0 -0
  110. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlLexer.py +0 -0
  111. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlLexer.tokens +0 -0
  112. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlParser.interp +0 -0
  113. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlParser.py +0 -0
  114. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlParser.tokens +0 -0
  115. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlParserListener.py +0 -0
  116. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/dpm_xlParserVisitor.py +0 -0
  117. /py_dpm/{grammar/dist → dpm_xl/grammar/generated}/listeners.py +0 -0
  118. /py_dpm/{DataTypes/TimeClasses.py → dpm_xl/types/time.py} +0 -0
  119. /py_dpm/{Utils → dpm_xl/utils}/tokens.py +0 -0
  120. /py_dpm/{Exceptions → exceptions}/messages.py +0 -0
  121. {pydpm_xl-0.1.39rc31.dist-info → pydpm_xl-0.2.0.dist-info}/WHEEL +0 -0
  122. {pydpm_xl-0.1.39rc31.dist-info → pydpm_xl-0.2.0.dist-info}/licenses/LICENSE +0 -0
  123. {pydpm_xl-0.1.39rc31.dist-info → pydpm_xl-0.2.0.dist-info}/top_level.txt +0 -0
@@ -1,984 +1,903 @@
1
- """
2
- Data Dictionary API
3
-
4
- This module provides ORM-based query methods for accessing the data dictionary.
5
- All methods use SQLAlchemy ORM instead of raw SQL for PostgreSQL compatibility.
6
- """
7
-
8
- from typing import List, Optional, Dict, Tuple, Any
9
- from sqlalchemy import and_, or_, func, distinct, text
10
- from sqlalchemy.orm import Session
11
-
12
- from py_dpm.db_utils import get_session, get_engine
13
- from py_dpm.models import (
14
- ViewDatapoints, TableVersion, ItemCategory, Cell, Property, DataType,
15
- KeyComposition, VariableVersion, Variable, Category, PropertyCategory,
16
- ModuleVersion, ModuleVersionComposition, Release, Header, HeaderVersion,
17
- TableVersionHeader, TableVersionCell
18
- )
19
-
20
-
21
- class DataDictionaryAPI:
22
- """
23
- Main API for querying the data dictionary using ORM.
24
-
25
- This class provides methods for:
26
- - Table/row/column reference lookups
27
- - Wildcard resolution
28
- - Item and sheet validation
29
- - Open key queries
30
- - Metadata retrieval
31
-
32
- All methods use SQLAlchemy ORM for database-agnostic queries.
33
- """
34
-
35
- def __init__(self, database_path: Optional[str] = None, connection_url: Optional[str] = None):
36
- """
37
- Initialize the Data Dictionary API.
38
-
39
- Args:
40
- database_path: Path to SQLite database (optional)
41
- connection_url: SQLAlchemy connection URL for PostgreSQL (optional)
42
- """
43
- engine = get_engine(database_path=database_path, connection_url=connection_url)
44
- self.session = get_session()
45
-
46
- # ==================== Reference Query Methods ====================
47
-
48
- def get_available_tables(self, release_id: Optional[int] = None) -> List[str]:
49
- """
50
- Get all available table codes from TableVersion.
51
-
52
- Args:
53
- release_id: Optional release ID to filter by
54
-
55
- Returns:
56
- List of table codes
57
- """
58
- query = self.session.query(distinct(TableVersion.code)).filter(
59
- TableVersion.code.isnot(None)
60
- )
61
-
62
- if release_id is not None:
63
- query = query.filter(
64
- or_(
65
- TableVersion.endreleaseid.is_(None),
66
- TableVersion.endreleaseid > release_id
67
- ),
68
- TableVersion.startreleaseid <= release_id
69
- )
70
-
71
- results = query.order_by(TableVersion.code).all()
72
- return [r[0] for r in results]
73
-
74
- def get_available_tables_from_datapoints(self, release_id: Optional[int] = None) -> List[str]:
75
- """
76
- Get all available table codes from datapoints.
77
- Always uses ViewDatapoints class methods for database compatibility.
78
-
79
- Args:
80
- release_id: Optional release ID to filter by
81
-
82
- Returns:
83
- List of table codes
84
- """
85
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
86
- base_query = ViewDatapoints.create_view_query(self.session)
87
- subq = base_query.subquery()
88
-
89
- query = self.session.query(distinct(subq.c.table_code)).filter(
90
- subq.c.table_code.isnot(None)
91
- )
92
-
93
- if release_id is not None:
94
- query = query.filter(
95
- or_(
96
- subq.c.end_release.is_(None),
97
- subq.c.end_release > release_id
98
- ),
99
- subq.c.start_release <= release_id
100
- )
101
-
102
- results = query.order_by(subq.c.table_code).all()
103
- return [r[0] for r in results]
104
-
105
- def get_available_rows(self, table_code: str, release_id: Optional[int] = None) -> List[str]:
106
- """
107
- Get all available row codes for a table.
108
- Always uses ViewDatapoints class methods for database compatibility.
109
-
110
- Args:
111
- table_code: Table code to query
112
- release_id: Optional release ID to filter by
113
-
114
- Returns:
115
- List of row codes
116
- """
117
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
118
- base_query = ViewDatapoints.create_view_query(self.session)
119
- subq = base_query.subquery()
120
-
121
- query = self.session.query(distinct(subq.c.row_code)).filter(
122
- subq.c.table_code == table_code,
123
- subq.c.row_code.isnot(None)
124
- )
125
-
126
- if release_id is not None:
127
- query = query.filter(
128
- or_(
129
- subq.c.end_release.is_(None),
130
- subq.c.end_release > release_id
131
- ),
132
- subq.c.start_release <= release_id
133
- )
134
-
135
- results = query.order_by(subq.c.row_code).all()
136
- return [r[0] for r in results]
137
-
138
- def get_available_columns(self, table_code: str, release_id: Optional[int] = None) -> List[str]:
139
- """
140
- Get all available column codes for a table.
141
- Always uses ViewDatapoints class methods for database compatibility.
142
-
143
- Args:
144
- table_code: Table code to query
145
- release_id: Optional release ID to filter by
146
-
147
- Returns:
148
- List of column codes
149
- """
150
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
151
- base_query = ViewDatapoints.create_view_query(self.session)
152
- subq = base_query.subquery()
153
-
154
- query = self.session.query(distinct(subq.c.column_code)).filter(
155
- subq.c.table_code == table_code,
156
- subq.c.column_code.isnot(None)
157
- )
158
-
159
- if release_id is not None:
160
- query = query.filter(
161
- or_(
162
- subq.c.end_release.is_(None),
163
- subq.c.end_release > release_id
164
- ),
165
- subq.c.start_release <= release_id
166
- )
167
-
168
- results = query.order_by(subq.c.column_code).all()
169
- return [r[0] for r in results]
170
-
171
- def get_reference_statistics(self, release_id: Optional[int] = None) -> Dict[str, int]:
172
- """
173
- Get statistics about rows and columns in the data dictionary.
174
- Always uses ViewDatapoints class methods for database compatibility.
175
-
176
- Args:
177
- release_id: Optional release ID to filter by
178
-
179
- Returns:
180
- Dictionary with row_count and column_count
181
- """
182
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
183
- base_query = ViewDatapoints.create_view_query(self.session)
184
- subq = base_query.subquery()
185
-
186
- # Base query for filtering
187
- base = self.session.query(subq)
188
-
189
- if release_id is not None:
190
- base = base.filter(
191
- or_(
192
- subq.c.end_release.is_(None),
193
- subq.c.end_release > release_id
194
- ),
195
- subq.c.start_release <= release_id
196
- )
197
-
198
- # Count distinct rows
199
- row_count = base.filter(subq.c.row_code.isnot(None)).with_entities(
200
- func.count(distinct(subq.c.row_code))
201
- ).scalar()
202
-
203
- # Count distinct columns
204
- column_count = base.filter(subq.c.column_code.isnot(None)).with_entities(
205
- func.count(distinct(subq.c.column_code))
206
- ).scalar()
207
-
208
- return {
209
- "row_count": row_count or 0,
210
- "column_count": column_count or 0
211
- }
212
-
213
- # ==================== Item Query Methods ====================
214
-
215
- def get_all_item_signatures(self, release_id: Optional[int] = None) -> List[str]:
216
- """
217
- Get all active item signatures from ItemCategory.
218
-
219
- Args:
220
- release_id: Optional release ID to filter by
221
-
222
- Returns:
223
- List of item signatures
224
- """
225
- query = self.session.query(distinct(ItemCategory.signature)).filter(
226
- ItemCategory.signature.isnot(None)
227
- )
228
-
229
- if release_id is not None:
230
- query = query.filter(
231
- or_(
232
- ItemCategory.endreleaseid.is_(None),
233
- ItemCategory.endreleaseid > release_id
234
- )
235
- )
236
- else:
237
- # Default: only active items (no end release)
238
- query = query.filter(ItemCategory.endreleaseid.is_(None))
239
-
240
- results = query.order_by(ItemCategory.signature).all()
241
- return [r[0] for r in results]
242
-
243
- def get_item_categories(self, release_id: Optional[int] = None) -> List[Tuple[str, str]]:
244
- """
245
- Get all item categories with code and signature.
246
-
247
- Args:
248
- release_id: Optional release ID to filter by
249
-
250
- Returns:
251
- List of tuples (code, signature)
252
- """
253
- query = self.session.query(
254
- ItemCategory.code,
255
- ItemCategory.signature
256
- ).filter(
257
- ItemCategory.code.isnot(None),
258
- ItemCategory.signature.isnot(None)
259
- )
260
-
261
- if release_id is not None:
262
- query = query.filter(
263
- or_(
264
- ItemCategory.endreleaseid.is_(None),
265
- ItemCategory.endreleaseid > release_id
266
- )
267
- )
268
-
269
- results = query.order_by(ItemCategory.code, ItemCategory.signature).all()
270
- return [(r[0], r[1]) for r in results]
271
-
272
- # ==================== Sheet Query Methods ====================
273
-
274
- def table_has_sheets(self, table_code: str, release_id: Optional[int] = None) -> bool:
275
- """
276
- Check if a table has any sheets defined.
277
- Always uses ViewDatapoints class methods for database compatibility.
278
-
279
- Args:
280
- table_code: Table code to check
281
- release_id: Optional release ID to filter by
282
-
283
- Returns:
284
- True if table has sheets, False otherwise
285
- """
286
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
287
- base_query = ViewDatapoints.create_view_query(self.session)
288
- subq = base_query.subquery()
289
-
290
- query = self.session.query(subq).filter(
291
- subq.c.table_code == table_code,
292
- subq.c.sheet_code.isnot(None),
293
- subq.c.sheet_code != ''
294
- )
295
-
296
- if release_id is not None:
297
- query = query.filter(
298
- or_(
299
- subq.c.end_release.is_(None),
300
- subq.c.end_release > release_id
301
- ),
302
- subq.c.start_release <= release_id
303
- )
304
-
305
- count = query.with_entities(func.count()).scalar()
306
- return count > 0
307
-
308
- def get_available_sheets(self, table_code: str, release_id: Optional[int] = None) -> List[str]:
309
- """
310
- Get all available sheet codes for a table.
311
- Always uses ViewDatapoints class methods for database compatibility.
312
-
313
- Args:
314
- table_code: Table code to query
315
- release_id: Optional release ID to filter by
316
-
317
- Returns:
318
- List of sheet codes
319
- """
320
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
321
- base_query = ViewDatapoints.create_view_query(self.session)
322
- subq = base_query.subquery()
323
-
324
- query = self.session.query(distinct(subq.c.sheet_code)).filter(
325
- subq.c.table_code == table_code,
326
- subq.c.sheet_code.isnot(None),
327
- subq.c.sheet_code != ''
328
- )
329
-
330
- if release_id is not None:
331
- query = query.filter(
332
- or_(
333
- subq.c.end_release.is_(None),
334
- subq.c.end_release > release_id
335
- ),
336
- subq.c.start_release <= release_id
337
- )
338
-
339
- results = query.order_by(subq.c.sheet_code).all()
340
- return [r[0] for r in results]
341
-
342
- def check_cell_exists(
343
- self,
344
- table_code: str,
345
- row_code: Optional[str] = None,
346
- column_code: Optional[str] = None,
347
- sheet_code: Optional[str] = None,
348
- release_id: Optional[int] = None
349
- ) -> bool:
350
- """
351
- Check if a cell reference exists in the datapoints.
352
- Always uses ViewDatapoints class methods for database compatibility.
353
-
354
- Args:
355
- table_code: Table code
356
- row_code: Optional row code
357
- column_code: Optional column code
358
- sheet_code: Optional sheet code
359
- release_id: Optional release ID to filter by
360
-
361
- Returns:
362
- True if cell exists, False otherwise
363
- """
364
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
365
- base_query = ViewDatapoints.create_view_query(self.session)
366
- subq = base_query.subquery()
367
-
368
- query = self.session.query(subq).filter(
369
- subq.c.table_code == table_code
370
- )
371
-
372
- if row_code:
373
- query = query.filter(subq.c.row_code == row_code)
374
-
375
- if column_code:
376
- query = query.filter(subq.c.column_code == column_code)
377
-
378
- if sheet_code:
379
- query = query.filter(subq.c.sheet_code == sheet_code)
380
-
381
- if release_id is not None:
382
- query = query.filter(
383
- or_(
384
- subq.c.end_release.is_(None),
385
- subq.c.end_release > release_id
386
- ),
387
- subq.c.start_release <= release_id
388
- )
389
-
390
- count = query.with_entities(func.count()).scalar()
391
- return count > 0
392
-
393
- def get_table_dimensions(self, table_code: str, release_id: Optional[int] = None) -> List[str]:
394
- """
395
- Get dimension codes for a table from KeyComposition.
396
-
397
- Args:
398
- table_code: Table code to query
399
- release_id: Optional release ID to filter by
400
-
401
- Returns:
402
- List of dimension codes
403
- """
404
- query = self.session.query(distinct(ItemCategory.code)).select_from(TableVersion).join(
405
- KeyComposition, TableVersion.keyid == KeyComposition.keyid
406
- ).join(
407
- VariableVersion, KeyComposition.variablevid == VariableVersion.variablevid
408
- ).join(
409
- ItemCategory, VariableVersion.propertyid == ItemCategory.itemid
410
- ).filter(
411
- TableVersion.code == table_code,
412
- ItemCategory.code.isnot(None)
413
- )
414
-
415
- if release_id is not None:
416
- query = query.filter(
417
- or_(
418
- TableVersion.endreleaseid.is_(None),
419
- TableVersion.endreleaseid > release_id
420
- ),
421
- TableVersion.startreleaseid <= release_id
422
- )
423
-
424
- results = query.order_by(ItemCategory.code).all()
425
- return [r[0] for r in results]
426
-
427
- def get_default_dimension_signature(
428
- self,
429
- dimension_code: str,
430
- release_id: Optional[int] = None
431
- ) -> Optional[str]:
432
- """
433
- Get the default signature for a dimension.
434
-
435
- Args:
436
- dimension_code: Dimension code to query
437
- release_id: Optional release ID to filter by
438
-
439
- Returns:
440
- Default signature or None
441
- """
442
- pattern1 = f"{dimension_code}:%"
443
- pattern2 = f"%:{dimension_code}"
444
-
445
- query = self.session.query(distinct(ItemCategory.signature)).filter(
446
- ItemCategory.code.isnot(None),
447
- ItemCategory.signature.isnot(None),
448
- or_(
449
- ItemCategory.signature.like(pattern1),
450
- ItemCategory.signature.like(pattern2)
451
- )
452
- )
453
-
454
- if release_id is not None:
455
- query = query.filter(
456
- or_(
457
- ItemCategory.endreleaseid.is_(None),
458
- ItemCategory.endreleaseid > release_id
459
- )
460
- )
461
-
462
- result = query.order_by(ItemCategory.signature).first()
463
- return result[0] if result else None
464
-
465
- def get_valid_sheet_code_for_dimension(
466
- self,
467
- dimension_code: str,
468
- signature: Optional[str] = None,
469
- release_id: Optional[int] = None
470
- ) -> Optional[str]:
471
- """
472
- Get a valid sheet code for a dimension and signature.
473
-
474
- Args:
475
- dimension_code: Dimension code to query
476
- signature: Optional signature to match
477
- release_id: Optional release ID to filter by
478
-
479
- Returns:
480
- Valid sheet code or None
481
- """
482
- if signature:
483
- pattern = f"{signature}%"
484
- query = self.session.query(ItemCategory.code).filter(
485
- ItemCategory.code.isnot(None),
486
- ItemCategory.signature.isnot(None),
487
- or_(
488
- ItemCategory.signature.like(pattern),
489
- ItemCategory.code == dimension_code
490
- )
491
- )
492
- else:
493
- query = self.session.query(ItemCategory.code).filter(
494
- ItemCategory.code == dimension_code
495
- )
496
-
497
- if release_id is not None:
498
- query = query.filter(
499
- or_(
500
- ItemCategory.endreleaseid.is_(None),
501
- ItemCategory.endreleaseid > release_id
502
- )
503
- )
504
-
505
- # Order by exact match first, then by code length and alphabetically
506
- # Note: SQLAlchemy's case expression for ordering
507
- from sqlalchemy import case, func as sa_func
508
- result = query.order_by(
509
- case((ItemCategory.code == dimension_code, 0), else_=1),
510
- sa_func.length(ItemCategory.code),
511
- ItemCategory.code
512
- ).first()
513
-
514
- return result[0] if result else None
515
-
516
- # ==================== Open Key Query Methods ====================
517
-
518
- def get_open_keys_for_table(
519
- self,
520
- table_code: str,
521
- release_id: Optional[int] = None
522
- ) -> List[Dict[str, Any]]:
523
- """
524
- Get open key information for a table.
525
-
526
- Args:
527
- table_code: Table code to query
528
- release_id: Optional release ID to filter by
529
-
530
- Returns:
531
- List of dictionaries with open key information
532
- """
533
- query = self.session.query(
534
- TableVersion.code.label('table_version_code'),
535
- ItemCategory.code.label('property_code'),
536
- DataType.name.label('data_type_name')
537
- ).select_from(DataType).join(
538
- Property, DataType.datatypeid == Property.datatypeid
539
- ).join(
540
- ItemCategory, Property.propertyid == ItemCategory.itemid
541
- ).join(
542
- VariableVersion, ItemCategory.itemid == VariableVersion.propertyid
543
- ).join(
544
- KeyComposition, VariableVersion.variablevid == KeyComposition.variablevid
545
- ).join(
546
- TableVersion, KeyComposition.keyid == TableVersion.keyid
547
- ).join(
548
- ModuleVersionComposition, TableVersion.tablevid == ModuleVersionComposition.tablevid
549
- ).join(
550
- ModuleVersion, ModuleVersionComposition.modulevid == ModuleVersion.modulevid
551
- ).filter(
552
- TableVersion.code == table_code
553
- )
554
-
555
- if release_id is not None:
556
- query = query.filter(
557
- or_(
558
- TableVersion.endreleaseid.is_(None),
559
- TableVersion.endreleaseid > release_id
560
- ),
561
- TableVersion.startreleaseid <= release_id
562
- )
563
-
564
- results = query.distinct().order_by(
565
- TableVersion.code,
566
- ItemCategory.code
567
- ).all()
568
-
569
- return [
570
- {
571
- "table_version_code": r.table_version_code,
572
- "property_code": r.property_code,
573
- "data_type_name": r.data_type_name
574
- }
575
- for r in results
576
- ]
577
-
578
- def get_category_signature(
579
- self,
580
- property_code: str,
581
- category_code: str,
582
- release_id: Optional[int] = None
583
- ) -> Optional[str]:
584
- """
585
- Get the signature for a category given a property code.
586
-
587
- Args:
588
- property_code: Property code
589
- category_code: Category code
590
- release_id: Optional release ID to filter by
591
-
592
- Returns:
593
- Category signature or None
594
- """
595
- ic_alias1 = ItemCategory
596
- ic_alias2 = ItemCategory.__table__.alias('ic2')
597
-
598
- query = self.session.query(ic_alias2.c.Signature).select_from(Property).join(
599
- ic_alias1, Property.propertyid == ic_alias1.itemid
600
- ).join(
601
- PropertyCategory, Property.propertyid == PropertyCategory.propertyid
602
- ).join(
603
- Category, PropertyCategory.categoryid == Category.categoryid
604
- ).join(
605
- ic_alias2, Category.categoryid == ic_alias2.c.CategoryID
606
- ).filter(
607
- ic_alias1.code == property_code,
608
- ic_alias2.c.Code == category_code
609
- )
610
-
611
- if release_id is not None:
612
- query = query.filter(
613
- or_(
614
- ic_alias1.endreleaseid.is_(None),
615
- ic_alias1.endreleaseid > release_id
616
- ),
617
- or_(
618
- ic_alias2.c.EndReleaseID.is_(None),
619
- ic_alias2.c.EndReleaseID > release_id
620
- )
621
- )
622
-
623
- result = query.first()
624
- return result[0] if result else None
625
-
626
- def get_available_items_for_key(
627
- self,
628
- property_code: str,
629
- release_id: Optional[int] = None
630
- ) -> List[Tuple[str, str]]:
631
- """
632
- Get available items (code, signature) for an open key property.
633
-
634
- Args:
635
- property_code: Property code
636
- release_id: Optional release ID to filter by
637
-
638
- Returns:
639
- List of tuples (code, signature)
640
- """
641
- ic_alias1 = ItemCategory
642
- ic_alias2 = ItemCategory.__table__.alias('ic2')
643
-
644
- query = self.session.query(
645
- distinct(ic_alias2.c.Code),
646
- ic_alias2.c.Signature
647
- ).select_from(Property).join(
648
- ic_alias1, Property.propertyid == ic_alias1.itemid
649
- ).join(
650
- PropertyCategory, Property.propertyid == PropertyCategory.propertyid
651
- ).join(
652
- Category, PropertyCategory.categoryid == Category.categoryid
653
- ).join(
654
- ic_alias2, Category.categoryid == ic_alias2.c.CategoryID
655
- ).filter(
656
- ic_alias1.code == property_code
657
- )
658
-
659
- if release_id is not None:
660
- query = query.filter(
661
- or_(
662
- ic_alias1.endreleaseid.is_(None),
663
- ic_alias1.endreleaseid > release_id
664
- ),
665
- or_(
666
- ic_alias2.c.EndReleaseID.is_(None),
667
- ic_alias2.c.EndReleaseID > release_id
668
- )
669
- )
670
-
671
- results = query.order_by(ic_alias2.c.Code).all()
672
- return [(r[0], r[1]) for r in results]
673
-
674
- # ==================== Metadata Query Methods ====================
675
-
676
- def get_datapoint_metadata(
677
- self,
678
- table_code: str,
679
- row_code: str,
680
- column_code: str,
681
- sheet_code: Optional[str] = None,
682
- release_id: Optional[int] = None
683
- ) -> Optional[Dict[str, Any]]:
684
- """
685
- Get metadata for a specific datapoint.
686
- Always uses ViewDatapoints class methods for database compatibility.
687
-
688
- Args:
689
- table_code: Table code
690
- row_code: Row code
691
- column_code: Column code
692
- sheet_code: Optional sheet code
693
- release_id: Optional release ID to filter by
694
-
695
- Returns:
696
- Dictionary with datapoint metadata or None
697
- """
698
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
699
- base_query = ViewDatapoints.create_view_query(self.session)
700
- subq = base_query.subquery()
701
-
702
- query = self.session.query(subq).filter(
703
- subq.c.table_code == table_code,
704
- subq.c.row_code == row_code,
705
- subq.c.column_code == column_code
706
- )
707
-
708
- if sheet_code:
709
- query = query.filter(subq.c.sheet_code == sheet_code)
710
-
711
- if release_id is not None:
712
- query = query.filter(
713
- or_(
714
- subq.c.end_release.is_(None),
715
- subq.c.end_release > release_id
716
- ),
717
- subq.c.start_release <= release_id
718
- )
719
-
720
- result = query.first()
721
-
722
- if result:
723
- return {
724
- "cell_code": result.cell_code,
725
- "table_code": result.table_code,
726
- "row_code": result.row_code,
727
- "column_code": result.column_code,
728
- "sheet_code": result.sheet_code,
729
- "variable_id": result.variable_id,
730
- "data_type": result.data_type,
731
- "table_vid": result.table_vid,
732
- "property_id": result.property_id,
733
- "start_release": result.start_release,
734
- "end_release": result.end_release,
735
- "cell_id": result.cell_id,
736
- "context_id": result.context_id,
737
- "variable_vid": result.variable_vid
738
- }
739
-
740
- return None
741
-
742
- def get_table_version(self, table_code: str, release_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
743
- """
744
- Get table version information.
745
-
746
- Args:
747
- table_code: Table code
748
- release_id: Optional release ID to filter by
749
-
750
- Returns:
751
- Dictionary with table version info or None
752
- """
753
- query = self.session.query(
754
- TableVersion.tablevid,
755
- TableVersion.code
756
- ).filter(
757
- TableVersion.code == table_code
758
- )
759
-
760
- if release_id is not None:
761
- query = query.filter(
762
- or_(
763
- TableVersion.endreleaseid.is_(None),
764
- TableVersion.endreleaseid > release_id
765
- ),
766
- TableVersion.startreleaseid <= release_id
767
- )
768
-
769
- result = query.first()
770
-
771
- if result:
772
- return {
773
- "table_vid": result.tablevid,
774
- "code": result.code
775
- }
776
-
777
- # Fallback with LIKE pattern
778
- pattern = f"{table_code}%"
779
- query = self.session.query(
780
- TableVersion.tablevid,
781
- TableVersion.code
782
- ).filter(
783
- TableVersion.code.like(pattern)
784
- )
785
-
786
- if release_id is not None:
787
- query = query.filter(
788
- or_(
789
- TableVersion.endreleaseid.is_(None),
790
- TableVersion.endreleaseid > release_id
791
- ),
792
- TableVersion.startreleaseid <= release_id
793
- )
794
-
795
- result = query.first()
796
-
797
- if result:
798
- return {
799
- "table_vid": result.tablevid,
800
- "code": result.code
801
- }
802
-
803
- return None
804
-
805
- def get_release_by_code(self, release_code: str) -> Optional[Dict[str, Any]]:
806
- """
807
- Get release information by code.
808
-
809
- Args:
810
- release_code: Release code
811
-
812
- Returns:
813
- Dictionary with release info or None
814
- """
815
- result = self.session.query(
816
- Release.releaseid,
817
- Release.code,
818
- Release.date
819
- ).filter(
820
- Release.code == release_code
821
- ).first()
822
-
823
- if result:
824
- return {
825
- "ReleaseID": result.releaseid,
826
- "code": result.code,
827
- "date": result.date
828
- }
829
-
830
- return None
831
-
832
- def get_latest_release(self) -> Optional[Dict[str, Any]]:
833
- """
834
- Get the latest released version.
835
-
836
- Returns:
837
- Dictionary with latest release info or None
838
- """
839
- result = self.session.query(
840
- Release.code,
841
- Release.date
842
- ).filter(
843
- Release.status == 'released'
844
- ).order_by(
845
- Release.date.desc()
846
- ).first()
847
-
848
- if result:
849
- return {
850
- "code": result.code,
851
- "date": result.date
852
- }
853
-
854
- return None
855
-
856
- def get_release_id_for_version(self, version_code: str) -> Optional[int]:
857
- """
858
- Get release ID for a version code.
859
-
860
- Args:
861
- version_code: Version code (e.g., "4.2")
862
-
863
- Returns:
864
- Release ID or None
865
- """
866
- result = self.session.query(Release.releaseid).filter(
867
- Release.code == version_code
868
- ).first()
869
-
870
- return result[0] if result else None
871
-
872
- def get_table_list(self, release_id: Optional[int] = None) -> List[str]:
873
- """
874
- Get list of all table names (used by database introspection).
875
-
876
- Args:
877
- release_id: Optional release ID to filter by
878
-
879
- Returns:
880
- List of table names
881
- """
882
- # For database introspection, we return distinct table codes from datapoints
883
- return self.get_available_tables_from_datapoints(release_id=release_id)
884
-
885
- def get_datapoints_count(self, release_id: Optional[int] = None) -> int:
886
- """
887
- Get count of datapoints.
888
- Always uses ViewDatapoints class methods for database compatibility.
889
-
890
- Args:
891
- release_id: Optional release ID to filter by
892
-
893
- Returns:
894
- Count of datapoints
895
- """
896
- # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
897
- base_query = ViewDatapoints.create_view_query(self.session)
898
- subq = base_query.subquery()
899
-
900
- query = self.session.query(func.count(subq.c.cell_code))
901
-
902
- if release_id is not None:
903
- query = query.select_from(subq).filter(
904
- or_(
905
- subq.c.end_release.is_(None),
906
- subq.c.end_release > release_id
907
- ),
908
- subq.c.start_release <= release_id
909
- )
910
-
911
- return query.scalar() or 0
912
-
913
- # ==================== Module and Table Query Methods ====================
914
-
915
- def get_all_variables_for_table(self, table_vid: int) -> Dict[str, str]:
916
- """
917
- Get all variables for a table version.
918
-
919
- Queries SOURCE_DB via TableVersionCell -> VariableVersion -> Property -> DataType
920
- to get all variable IDs with their single-char type codes.
921
-
922
- Args:
923
- table_vid: Table version ID
924
-
925
- Returns:
926
- Dictionary mapping variable_id (str) to type_code (str)
927
- Type codes are single characters from DataType.Code (e.g., 'm', 'e', 'b', 'p')
928
- """
929
- query = self.session.query(
930
- Variable.variableid,
931
- DataType.code
932
- ).select_from(TableVersionCell).join(
933
- VariableVersion, TableVersionCell.variablevid == VariableVersion.variablevid
934
- ).join(
935
- Variable, VariableVersion.variableid == Variable.variableid
936
- ).join(
937
- Property, VariableVersion.propertyid == Property.propertyid
938
- ).join(
939
- DataType, Property.datatypeid == DataType.datatypeid
940
- ).filter(
941
- TableVersionCell.tablevid == table_vid
942
- ).distinct()
943
-
944
- results = query.all()
945
- # IMPORTANT: Convert to int first to avoid ".0" suffix from potential float values
946
- return {str(int(r.variableid)): r.code for r in results if r.code is not None}
947
-
948
- def get_all_tables_for_module(self, module_vid: int) -> List[Dict[str, Any]]:
949
- """
950
- Get ALL tables belonging to a module version.
951
-
952
- Queries SOURCE_DB via ModuleVersionComposition to find all tables
953
- in a module, regardless of whether they're referenced in validations.
954
-
955
- Args:
956
- module_vid: Module version ID
957
-
958
- Returns:
959
- List of dicts with table_vid, table_code, table_name
960
- """
961
- query = self.session.query(
962
- TableVersion.tablevid,
963
- TableVersion.code,
964
- TableVersion.name
965
- ).select_from(ModuleVersionComposition).join(
966
- TableVersion, ModuleVersionComposition.tablevid == TableVersion.tablevid
967
- ).filter(
968
- ModuleVersionComposition.modulevid == module_vid
969
- ).distinct().order_by(TableVersion.code)
970
-
971
- results = query.all()
972
- return [
973
- {
974
- "table_vid": r.tablevid,
975
- "table_code": r.code,
976
- "table_name": r.name
977
- }
978
- for r in results
979
- ]
980
-
981
- def __del__(self):
982
- """Clean up resources."""
983
- if hasattr(self, 'session') and self.session:
984
- self.session.close()
1
+ """
2
+ Data Dictionary API
3
+
4
+ This module provides ORM-based query methods for accessing the data dictionary.
5
+ All methods use SQLAlchemy ORM instead of raw SQL for PostgreSQL compatibility.
6
+ """
7
+
8
+ from typing import List, Optional, Dict, Tuple, Any
9
+ from sqlalchemy import and_, or_, func, distinct, text
10
+ from sqlalchemy.orm import Session
11
+
12
+ from py_dpm.dpm.utils import get_session, get_engine
13
+ from py_dpm.dpm.models import (
14
+ ViewDatapoints,
15
+ TableVersion,
16
+ ItemCategory,
17
+ Cell,
18
+ Property,
19
+ DataType,
20
+ KeyComposition,
21
+ VariableVersion,
22
+ Variable,
23
+ Category,
24
+ PropertyCategory,
25
+ ModuleVersion,
26
+ ModuleVersionComposition,
27
+ Release,
28
+ Header,
29
+ HeaderVersion,
30
+ TableVersionHeader,
31
+ TableVersionCell,
32
+ )
33
+ from py_dpm.dpm.queries.tables import TableQuery
34
+ from py_dpm.dpm.queries.glossary import ItemQuery
35
+ from py_dpm.dpm.queries.basic_objects import ReleaseQuery
36
+
37
+
38
+ class DataDictionaryAPI:
39
+ """
40
+ Main API for querying the data dictionary using ORM.
41
+
42
+ This class provides methods for:
43
+ - Table/row/column reference lookups
44
+ - Wildcard resolution
45
+ - Item and sheet validation
46
+ - Open key queries
47
+ - Metadata retrieval
48
+
49
+ All methods use SQLAlchemy ORM for database-agnostic queries.
50
+ """
51
+
52
+ def __init__(
53
+ self, database_path: Optional[str] = None, connection_url: Optional[str] = None
54
+ ):
55
+ """
56
+ Initialize the Data Dictionary API.
57
+
58
+ Args:
59
+ database_path: Path to SQLite database (optional)
60
+ connection_url: SQLAlchemy connection URL for PostgreSQL (optional)
61
+ """
62
+ engine = get_engine(database_path=database_path, connection_url=connection_url)
63
+ self.session = get_session()
64
+
65
+ # ==================== Release Query Methods ====================
66
+
67
+ def get_releases(self) -> List[Dict[str, Any]]:
68
+ """
69
+ Fetch list of available releases from database.
70
+
71
+ Returns:
72
+ List of dictionaries containing release info
73
+ """
74
+ # Use ReleaseQuery
75
+ query = ReleaseQuery.get_all_releases(self.session)
76
+ return query.to_dict()
77
+
78
+ def get_release_by_id(self, release_id: int) -> Optional[Dict[str, Any]]:
79
+ """
80
+ Fetch a specific release by its ID.
81
+
82
+ Args:
83
+ release_id: The ID of the release to fetch
84
+
85
+ Returns:
86
+ Dictionary with release info or None if not found
87
+ """
88
+ query = ReleaseQuery.get_release_by_id(self.session, release_id)
89
+ result = query.to_dict()
90
+ return result[0] if result else None
91
+
92
+ def get_release_by_code(self, release_code: str) -> Optional[Dict[str, Any]]:
93
+ """
94
+ Fetch a specific release by its code.
95
+
96
+ Args:
97
+ release_code: The code of the release to fetch
98
+
99
+ Returns:
100
+ Dictionary with release info or None if not found
101
+ """
102
+ query = ReleaseQuery.get_release_by_code(self.session, release_code)
103
+ result = query.to_dict()
104
+ return result[0] if result else None
105
+
106
+ # ==================== Reference Query Methods ====================
107
+
108
+ def get_tables(
109
+ self,
110
+ release_id: Optional[int] = None,
111
+ date: Optional[str] = None,
112
+ release_code: Optional[str] = None,
113
+ ) -> List[str]:
114
+ """
115
+ Get all available table codes from TableVersion.
116
+
117
+ Args:
118
+ release_id: Optional release ID to filter by
119
+ date: Optional date string (YYYY-MM-DD) to filter by. Mutually exclusive with release_id.
120
+
121
+ Returns:
122
+ List of table codes
123
+ """
124
+ # Use TableQuery
125
+ query = TableQuery.get_tables(self.session, release_id, date, release_code)
126
+
127
+ return query.to_dict()
128
+
129
+ def get_available_tables_from_datapoints(
130
+ self, release_id: Optional[int] = None
131
+ ) -> List[str]:
132
+ """
133
+ Get all available table codes from datapoints.
134
+ Always uses ViewDatapoints class methods for database compatibility.
135
+
136
+ Args:
137
+ release_id: Optional release ID to filter by
138
+
139
+ Returns:
140
+ List of table codes
141
+ """
142
+ # Use TableQuery
143
+ query = TableQuery.get_available_tables_from_datapoints(
144
+ self.session, release_id
145
+ )
146
+ return query.to_dict()
147
+
148
+ def get_available_rows(
149
+ self, table_code: str, release_id: Optional[int] = None
150
+ ) -> List[str]:
151
+ """
152
+ Get all available row codes for a table.
153
+ Always uses ViewDatapoints class methods for database compatibility.
154
+
155
+ Args:
156
+ table_code: Table code to query
157
+ release_id: Optional release ID to filter by
158
+
159
+ Returns:
160
+ List of row codes
161
+ """
162
+ # Use TableQuery
163
+ query = TableQuery.get_available_rows(self.session, table_code, release_id)
164
+ return query.to_dict()
165
+
166
+ def get_available_columns(
167
+ self, table_code: str, release_id: Optional[int] = None
168
+ ) -> List[str]:
169
+ """
170
+ Get all available column codes for a table.
171
+ Always uses ViewDatapoints class methods for database compatibility.
172
+
173
+ Args:
174
+ table_code: Table code to query
175
+ release_id: Optional release ID to filter by
176
+
177
+ Returns:
178
+ List of column codes
179
+ """
180
+ # Use TableQuery
181
+ query = TableQuery.get_available_columns(self.session, table_code, release_id)
182
+ return query.to_dict()
183
+
184
+ # ==================== Item Query Methods ====================
185
+
186
+ def get_all_item_signatures(self, release_id: Optional[int] = None) -> List[str]:
187
+ """
188
+ Get all active item signatures from ItemCategory.
189
+
190
+ Args:
191
+ release_id: Optional release ID to filter by
192
+
193
+ Returns:
194
+ List of item signatures
195
+ """
196
+ # Use ItemQuery
197
+ query = ItemQuery.get_all_item_signatures(self.session, release_id)
198
+ return query.to_dict()
199
+
200
+ def get_item_categories(
201
+ self, release_id: Optional[int] = None
202
+ ) -> List[Tuple[str, str]]:
203
+ """
204
+ Get all item categories with code and signature.
205
+
206
+ Args:
207
+ release_id: Optional release ID to filter by
208
+
209
+ Returns:
210
+ List of tuples (code, signature)
211
+ """
212
+ # Use ItemQuery
213
+ query = ItemQuery.get_item_categories(self.session, release_id)
214
+ return query.to_dict()
215
+
216
+ # ==================== Sheet Query Methods ====================
217
+
218
+ def table_has_sheets(
219
+ self, table_code: str, release_id: Optional[int] = None
220
+ ) -> bool:
221
+ """
222
+ Check if a table has any sheets defined.
223
+ Always uses ViewDatapoints class methods for database compatibility.
224
+
225
+ Args:
226
+ table_code: Table code to check
227
+ release_id: Optional release ID to filter by
228
+
229
+ Returns:
230
+ True if table has sheets, False otherwise
231
+ """
232
+ # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
233
+ base_query = ViewDatapoints.create_view_query(self.session)
234
+ subq = base_query.subquery()
235
+
236
+ query = self.session.query(subq).filter(
237
+ subq.c.table_code == table_code,
238
+ subq.c.sheet_code.isnot(None),
239
+ subq.c.sheet_code != "",
240
+ )
241
+
242
+ if release_id is not None:
243
+ query = query.filter(
244
+ or_(subq.c.end_release.is_(None), subq.c.end_release > release_id),
245
+ subq.c.start_release <= release_id,
246
+ )
247
+
248
+ count = query.with_entities(func.count()).scalar()
249
+ return count > 0
250
+
251
+ def get_available_sheets(
252
+ self, table_code: str, release_id: Optional[int] = None
253
+ ) -> List[str]:
254
+ """
255
+ Get all available sheet codes for a table.
256
+ Always uses ViewDatapoints class methods for database compatibility.
257
+
258
+ Args:
259
+ table_code: Table code to query
260
+ release_id: Optional release ID to filter by
261
+
262
+ Returns:
263
+ List of sheet codes
264
+ """
265
+ # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
266
+ base_query = ViewDatapoints.create_view_query(self.session)
267
+ subq = base_query.subquery()
268
+
269
+ # Use TableQuery
270
+ query = TableQuery.get_available_sheets(self.session, table_code, release_id)
271
+ return query.to_dict()
272
+
273
+ def check_cell_exists(
274
+ self,
275
+ table_code: str,
276
+ row_code: Optional[str] = None,
277
+ column_code: Optional[str] = None,
278
+ sheet_code: Optional[str] = None,
279
+ release_id: Optional[int] = None,
280
+ ) -> bool:
281
+ """
282
+ Check if a cell reference exists in the datapoints.
283
+ Always uses ViewDatapoints class methods for database compatibility.
284
+
285
+ Args:
286
+ table_code: Table code
287
+ row_code: Optional row code
288
+ column_code: Optional column code
289
+ sheet_code: Optional sheet code
290
+ release_id: Optional release ID to filter by
291
+
292
+ Returns:
293
+ True if cell exists, False otherwise
294
+ """
295
+ # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
296
+ base_query = ViewDatapoints.create_view_query(self.session)
297
+ subq = base_query.subquery()
298
+
299
+ query = self.session.query(subq).filter(subq.c.table_code == table_code)
300
+
301
+ if row_code:
302
+ query = query.filter(subq.c.row_code == row_code)
303
+
304
+ if column_code:
305
+ query = query.filter(subq.c.column_code == column_code)
306
+
307
+ if sheet_code:
308
+ query = query.filter(subq.c.sheet_code == sheet_code)
309
+
310
+ if release_id is not None:
311
+ query = query.filter(
312
+ or_(subq.c.end_release.is_(None), subq.c.end_release > release_id),
313
+ subq.c.start_release <= release_id,
314
+ )
315
+
316
+ count = query.with_entities(func.count()).scalar()
317
+ return count > 0
318
+
319
+ def get_table_dimensions(
320
+ self, table_code: str, release_id: Optional[int] = None
321
+ ) -> List[str]:
322
+ """
323
+ Get dimension codes for a table from KeyComposition.
324
+
325
+ Args:
326
+ table_code: Table code to query
327
+ release_id: Optional release ID to filter by
328
+
329
+ Returns:
330
+ List of dimension codes
331
+ """
332
+ query = (
333
+ self.session.query(distinct(ItemCategory.code))
334
+ .select_from(TableVersion)
335
+ .join(KeyComposition, TableVersion.keyid == KeyComposition.keyid)
336
+ .join(
337
+ VariableVersion,
338
+ KeyComposition.variablevid == VariableVersion.variablevid,
339
+ )
340
+ .join(ItemCategory, VariableVersion.propertyid == ItemCategory.itemid)
341
+ .filter(TableVersion.code == table_code, ItemCategory.code.isnot(None))
342
+ )
343
+
344
+ if release_id is not None:
345
+ query = query.filter(
346
+ or_(
347
+ TableVersion.endreleaseid.is_(None),
348
+ TableVersion.endreleaseid > release_id,
349
+ ),
350
+ TableVersion.startreleaseid <= release_id,
351
+ )
352
+
353
+ results = query.order_by(ItemCategory.code).all()
354
+ return [r[0] for r in results]
355
+
356
+ def get_default_dimension_signature(
357
+ self, dimension_code: str, release_id: Optional[int] = None
358
+ ) -> Optional[str]:
359
+ """
360
+ Get the default signature for a dimension.
361
+
362
+ Args:
363
+ dimension_code: Dimension code to query
364
+ release_id: Optional release ID to filter by
365
+
366
+ Returns:
367
+ Default signature or None
368
+ """
369
+ pattern1 = f"{dimension_code}:%"
370
+ pattern2 = f"%:{dimension_code}"
371
+
372
+ query = self.session.query(distinct(ItemCategory.signature)).filter(
373
+ ItemCategory.code.isnot(None),
374
+ ItemCategory.signature.isnot(None),
375
+ or_(
376
+ ItemCategory.signature.like(pattern1),
377
+ ItemCategory.signature.like(pattern2),
378
+ ),
379
+ )
380
+
381
+ if release_id is not None:
382
+ query = query.filter(
383
+ or_(
384
+ ItemCategory.endreleaseid.is_(None),
385
+ ItemCategory.endreleaseid > release_id,
386
+ )
387
+ )
388
+
389
+ result = query.order_by(ItemCategory.signature).first()
390
+ return result[0] if result else None
391
+
392
+ def get_valid_sheet_code_for_dimension(
393
+ self,
394
+ dimension_code: str,
395
+ signature: Optional[str] = None,
396
+ release_id: Optional[int] = None,
397
+ ) -> Optional[str]:
398
+ """
399
+ Get a valid sheet code for a dimension and signature.
400
+
401
+ Args:
402
+ dimension_code: Dimension code to query
403
+ signature: Optional signature to match
404
+ release_id: Optional release ID to filter by
405
+
406
+ Returns:
407
+ Valid sheet code or None
408
+ """
409
+ if signature:
410
+ pattern = f"{signature}%"
411
+ query = self.session.query(ItemCategory.code).filter(
412
+ ItemCategory.code.isnot(None),
413
+ ItemCategory.signature.isnot(None),
414
+ or_(
415
+ ItemCategory.signature.like(pattern),
416
+ ItemCategory.code == dimension_code,
417
+ ),
418
+ )
419
+ else:
420
+ query = self.session.query(ItemCategory.code).filter(
421
+ ItemCategory.code == dimension_code
422
+ )
423
+
424
+ if release_id is not None:
425
+ query = query.filter(
426
+ or_(
427
+ ItemCategory.endreleaseid.is_(None),
428
+ ItemCategory.endreleaseid > release_id,
429
+ )
430
+ )
431
+
432
+ # Order by exact match first, then by code length and alphabetically
433
+ # Note: SQLAlchemy's case expression for ordering
434
+ from sqlalchemy import case, func as sa_func
435
+
436
+ result = query.order_by(
437
+ case((ItemCategory.code == dimension_code, 0), else_=1),
438
+ sa_func.length(ItemCategory.code),
439
+ ItemCategory.code,
440
+ ).first()
441
+
442
+ return result[0] if result else None
443
+
444
+ # ==================== Open Key Query Methods ====================
445
+
446
+ def get_open_keys_for_table(
447
+ self, table_code: str, release_id: Optional[int] = None
448
+ ) -> List[Dict[str, Any]]:
449
+ """
450
+ Get open key information for a table.
451
+
452
+ Args:
453
+ table_code: Table code to query
454
+ release_id: Optional release ID to filter by
455
+
456
+ Returns:
457
+ List of dictionaries with open key info
458
+ """
459
+ query = (
460
+ self.session.query(
461
+ TableVersion.code.label("table_version_code"),
462
+ ItemCategory.code.label("property_code"),
463
+ DataType.name.label("data_type_name"),
464
+ )
465
+ .select_from(DataType)
466
+ .join(Property, DataType.datatypeid == Property.datatypeid)
467
+ .join(ItemCategory, Property.propertyid == ItemCategory.itemid)
468
+ .join(VariableVersion, ItemCategory.itemid == VariableVersion.propertyid)
469
+ .join(
470
+ KeyComposition,
471
+ VariableVersion.variablevid == KeyComposition.variablevid,
472
+ )
473
+ .join(TableVersion, KeyComposition.keyid == TableVersion.keyid)
474
+ .join(
475
+ ModuleVersionComposition,
476
+ TableVersion.tablevid == ModuleVersionComposition.tablevid,
477
+ )
478
+ .join(
479
+ ModuleVersion,
480
+ ModuleVersionComposition.modulevid == ModuleVersion.modulevid,
481
+ )
482
+ .filter(TableVersion.code == table_code)
483
+ )
484
+
485
+ if release_id is not None:
486
+ query = query.filter(
487
+ or_(
488
+ TableVersion.endreleaseid.is_(None),
489
+ TableVersion.endreleaseid > release_id,
490
+ ),
491
+ TableVersion.startreleaseid <= release_id,
492
+ )
493
+
494
+ results = query.distinct().order_by(TableVersion.code, ItemCategory.code).all()
495
+
496
+ return [
497
+ {
498
+ "table_version_code": r.table_version_code,
499
+ "property_code": r.property_code,
500
+ "data_type_name": r.data_type_name,
501
+ }
502
+ for r in results
503
+ ]
504
+
505
+ def get_category_signature(
506
+ self, property_code: str, category_code: str, release_id: Optional[int] = None
507
+ ) -> Optional[str]:
508
+ """
509
+ Get the signature for a category given a property code.
510
+
511
+ Args:
512
+ property_code: Property code
513
+ category_code: Category code
514
+ release_id: Optional release ID to filter by
515
+
516
+ Returns:
517
+ Category signature or None
518
+ """
519
+ ic_alias1 = ItemCategory
520
+ ic_alias2 = ItemCategory.__table__.alias("ic2")
521
+
522
+ query = (
523
+ self.session.query(ic_alias2.c.Signature)
524
+ .select_from(Property)
525
+ .join(ic_alias1, Property.propertyid == ic_alias1.itemid)
526
+ .join(PropertyCategory, Property.propertyid == PropertyCategory.propertyid)
527
+ .join(Category, PropertyCategory.categoryid == Category.categoryid)
528
+ .join(ic_alias2, Category.categoryid == ic_alias2.c.CategoryID)
529
+ .filter(ic_alias1.code == property_code, ic_alias2.c.Code == category_code)
530
+ )
531
+
532
+ if release_id is not None:
533
+ query = query.filter(
534
+ or_(
535
+ ic_alias1.endreleaseid.is_(None),
536
+ ic_alias1.endreleaseid > release_id,
537
+ ),
538
+ or_(
539
+ ic_alias2.c.EndReleaseID.is_(None),
540
+ ic_alias2.c.EndReleaseID > release_id,
541
+ ),
542
+ )
543
+
544
+ result = query.first()
545
+ return result[0] if result else None
546
+
547
+ def get_available_items_for_key(
548
+ self, property_code: str, release_id: Optional[int] = None
549
+ ) -> List[Dict[str, Any]]:
550
+ """
551
+ Get available items (code, signature) for an open key property.
552
+
553
+ Args:
554
+ property_code: Property code
555
+ release_id: Optional release ID to filter by
556
+
557
+ Returns:
558
+ List of dictionaries with item info (code, signature)
559
+ """
560
+ ic_alias1 = ItemCategory
561
+ ic_alias2 = ItemCategory.__table__.alias("ic2")
562
+
563
+ query = (
564
+ self.session.query(distinct(ic_alias2.c.Code), ic_alias2.c.Signature)
565
+ .select_from(Property)
566
+ .join(ic_alias1, Property.propertyid == ic_alias1.itemid)
567
+ .join(PropertyCategory, Property.propertyid == PropertyCategory.propertyid)
568
+ .join(Category, PropertyCategory.categoryid == Category.categoryid)
569
+ .join(ic_alias2, Category.categoryid == ic_alias2.c.CategoryID)
570
+ .filter(ic_alias1.code == property_code)
571
+ )
572
+
573
+ if release_id is not None:
574
+ query = query.filter(
575
+ or_(
576
+ ic_alias1.endreleaseid.is_(None),
577
+ ic_alias1.endreleaseid > release_id,
578
+ ),
579
+ or_(
580
+ ic_alias2.c.EndReleaseID.is_(None),
581
+ ic_alias2.c.EndReleaseID > release_id,
582
+ ),
583
+ )
584
+
585
+ results = query.order_by(ic_alias2.c.Code).all()
586
+ return [{"code": r[0], "signature": r[1]} for r in results]
587
+
588
+ # ==================== Metadata Query Methods ====================
589
+
590
+ def get_datapoint_metadata(
591
+ self,
592
+ table_code: str,
593
+ row_code: str,
594
+ column_code: str,
595
+ sheet_code: Optional[str] = None,
596
+ release_id: Optional[int] = None,
597
+ ) -> Optional[Dict[str, Any]]:
598
+ """
599
+ Get metadata for a specific datapoint.
600
+ Always uses ViewDatapoints class methods for database compatibility.
601
+
602
+ Args:
603
+ table_code: Table code
604
+ row_code: Row code
605
+ column_code: Column code
606
+ sheet_code: Optional sheet code
607
+ release_id: Optional release ID to filter by
608
+
609
+ Returns:
610
+ Dictionary with datapoint metadata or None
611
+ """
612
+ # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
613
+ base_query = ViewDatapoints.create_view_query(self.session)
614
+ subq = base_query.subquery()
615
+
616
+ query = self.session.query(subq).filter(
617
+ subq.c.table_code == table_code,
618
+ subq.c.row_code == row_code,
619
+ subq.c.column_code == column_code,
620
+ )
621
+
622
+ if sheet_code:
623
+ query = query.filter(subq.c.sheet_code == sheet_code)
624
+
625
+ if release_id is not None:
626
+ query = query.filter(
627
+ or_(subq.c.end_release.is_(None), subq.c.end_release > release_id),
628
+ subq.c.start_release <= release_id,
629
+ )
630
+
631
+ result = query.first()
632
+
633
+ if result:
634
+ return {
635
+ "cell_code": result.cell_code,
636
+ "table_code": result.table_code,
637
+ "row_code": result.row_code,
638
+ "column_code": result.column_code,
639
+ "sheet_code": result.sheet_code,
640
+ "variable_id": result.variable_id,
641
+ "data_type": result.data_type,
642
+ "table_vid": result.table_vid,
643
+ "property_id": result.property_id,
644
+ "start_release": result.start_release,
645
+ "end_release": result.end_release,
646
+ "cell_id": result.cell_id,
647
+ "context_id": result.context_id,
648
+ "variable_vid": result.variable_vid,
649
+ }
650
+
651
+ return None
652
+
653
+ def get_table_version(
654
+ self, table_code: str, release_id: Optional[int] = None
655
+ ) -> Optional[Dict[str, Any]]:
656
+ """
657
+ Get table version information.
658
+
659
+ Args:
660
+ table_code: Table code
661
+ release_id: Optional release ID to filter by
662
+
663
+ Returns:
664
+ Dictionary with table version info or None
665
+ """
666
+ query = self.session.query(TableVersion.tablevid, TableVersion.code).filter(
667
+ TableVersion.code == table_code
668
+ )
669
+
670
+ if release_id is not None:
671
+ query = query.filter(
672
+ or_(
673
+ TableVersion.endreleaseid.is_(None),
674
+ TableVersion.endreleaseid > release_id,
675
+ ),
676
+ TableVersion.startreleaseid <= release_id,
677
+ )
678
+
679
+ result = query.first()
680
+
681
+ if result:
682
+ return {
683
+ "table_vid": result.tablevid,
684
+ "code": result.code,
685
+ "name": "", # Not fetched in first query
686
+ "description": "", # Not fetched in first query
687
+ }
688
+
689
+ # Fallback with LIKE pattern
690
+ pattern = f"{table_code}%"
691
+ query = self.session.query(TableVersion.tablevid, TableVersion.code).filter(
692
+ TableVersion.code.like(pattern)
693
+ )
694
+
695
+ if release_id is not None:
696
+ query = query.filter(
697
+ or_(
698
+ TableVersion.endreleaseid.is_(None),
699
+ TableVersion.endreleaseid > release_id,
700
+ ),
701
+ TableVersion.startreleaseid <= release_id,
702
+ )
703
+
704
+ result = query.first()
705
+
706
+ if result:
707
+ return {
708
+ "table_vid": result.tablevid,
709
+ "code": result.code,
710
+ "name": "", # Not fetched in fallback
711
+ "description": "", # Not fetched in fallback
712
+ }
713
+
714
+ return None
715
+
716
+ def get_release_by_code(self, release_code: str) -> Optional[Dict[str, Any]]:
717
+ """
718
+ Get release information by code.
719
+
720
+ Args:
721
+ release_code: Release code
722
+
723
+ Returns:
724
+ Dictionary with release info or None
725
+ """
726
+ result = (
727
+ self.session.query(Release.releaseid, Release.code, Release.date)
728
+ .filter(Release.code == release_code)
729
+ .first()
730
+ )
731
+
732
+ if result:
733
+ return {
734
+ "ReleaseID": result.releaseid,
735
+ "code": result.code,
736
+ "date": result.date,
737
+ }
738
+
739
+ return None
740
+
741
+ def get_latest_release(self) -> Optional[Dict[str, Any]]:
742
+ """
743
+ Get the latest released version.
744
+
745
+ Returns:
746
+ Dictionary with latest release info or None
747
+ """
748
+ result = (
749
+ self.session.query(Release.code, Release.date)
750
+ .filter(Release.status == "released")
751
+ .order_by(Release.date.desc())
752
+ .first()
753
+ )
754
+
755
+ if result:
756
+ return {"code": result.code, "date": result.date}
757
+
758
+ return None
759
+
760
+ def get_release_id_for_version(self, version_code: str) -> Optional[int]:
761
+ """
762
+ Get release ID for a version code.
763
+
764
+ Args:
765
+ version_code: Version code (e.g., "4.2")
766
+
767
+ Returns:
768
+ Release ID or None
769
+ """
770
+ result = (
771
+ self.session.query(Release.releaseid)
772
+ .filter(Release.code == version_code)
773
+ .first()
774
+ )
775
+
776
+ return result[0] if result else None
777
+
778
+ def get_table_list(self, release_id: Optional[int] = None) -> List[str]:
779
+ """
780
+ Get list of all table names (used by database introspection).
781
+
782
+ Args:
783
+ release_id: Optional release ID to filter by
784
+
785
+ Returns:
786
+ List of table names
787
+ """
788
+ # For database introspection, we return distinct table codes from datapoints
789
+ return self.get_available_tables_from_datapoints(release_id=release_id)
790
+
791
+ def get_datapoints_count(self, release_id: Optional[int] = None) -> int:
792
+ """
793
+ Get count of datapoints.
794
+ Always uses ViewDatapoints class methods for database compatibility.
795
+
796
+ Args:
797
+ release_id: Optional release ID to filter by
798
+
799
+ Returns:
800
+ Count of datapoints
801
+ """
802
+ # Use ViewDatapoints class method (works for both SQLite and PostgreSQL)
803
+ base_query = ViewDatapoints.create_view_query(self.session)
804
+ subq = base_query.subquery()
805
+
806
+ query = self.session.query(func.count(subq.c.cell_code))
807
+
808
+ if release_id is not None:
809
+ query = query.select_from(subq).filter(
810
+ or_(subq.c.end_release.is_(None), subq.c.end_release > release_id),
811
+ subq.c.start_release <= release_id,
812
+ )
813
+
814
+ return query.scalar() or 0
815
+
816
+ # ==================== Module and Table Query Methods ====================
817
+
818
+ def get_all_variables_for_table(self, table_vid: int) -> Dict[str, str]:
819
+ """
820
+ Get all variables for a table version.
821
+
822
+ Queries SOURCE_DB via TableVersionCell -> VariableVersion -> Property -> DataType
823
+ to get all variable IDs with their single-char type codes.
824
+
825
+ Args:
826
+ table_vid: Table version ID
827
+
828
+ Returns:
829
+ Dictionary mapping variable_id (str) to type_code (str)
830
+ Type codes are single characters from DataType.Code (e.g., 'm', 'e', 'b', 'p')
831
+ """
832
+ query = (
833
+ self.session.query(Variable.variableid, DataType.code)
834
+ .select_from(TableVersionCell)
835
+ .join(
836
+ VariableVersion,
837
+ TableVersionCell.variablevid == VariableVersion.variablevid,
838
+ )
839
+ .join(Variable, VariableVersion.variableid == Variable.variableid)
840
+ .join(Property, VariableVersion.propertyid == Property.propertyid)
841
+ .join(DataType, Property.datatypeid == DataType.datatypeid)
842
+ .filter(TableVersionCell.tablevid == table_vid)
843
+ .distinct()
844
+ )
845
+
846
+ results = query.all()
847
+ # IMPORTANT: Convert to int first to avoid ".0" suffix from potential float values
848
+ return {str(int(r.variableid)): r.code for r in results if r.code is not None}
849
+
850
+ def get_all_tables_for_module(self, module_vid: int) -> List[Dict[str, Any]]:
851
+ """
852
+ Get ALL tables belonging to a module version.
853
+
854
+ Queries SOURCE_DB via ModuleVersionComposition to find all tables
855
+ in a module, regardless of whether they're referenced in validations.
856
+
857
+ Args:
858
+ module_vid: Module version ID
859
+
860
+ Returns:
861
+ List of dicts with table_vid, table_code, table_name
862
+ """
863
+ query = (
864
+ self.session.query(
865
+ TableVersion.tablevid, TableVersion.code, TableVersion.name
866
+ )
867
+ .select_from(ModuleVersionComposition)
868
+ .join(
869
+ TableVersion, ModuleVersionComposition.tablevid == TableVersion.tablevid
870
+ )
871
+ .filter(ModuleVersionComposition.modulevid == module_vid)
872
+ .distinct()
873
+ .order_by(TableVersion.code)
874
+ )
875
+
876
+ results = query.all()
877
+ return [
878
+ {"table_vid": r.tablevid, "table_code": r.code, "table_name": r.name}
879
+ for r in results
880
+ ]
881
+
882
+ def __del__(self):
883
+ """Clean up resources."""
884
+ if hasattr(self, "session") and self.session:
885
+ self.session.close()
886
+
887
+ def close(self):
888
+ """
889
+ Explicitly close the underlying SQLAlchemy session.
890
+
891
+ This is a no-op if the session is already closed or missing.
892
+ """
893
+ if hasattr(self, "session") and self.session:
894
+ try:
895
+ self.session.close()
896
+ except Exception:
897
+ pass
898
+
899
+ def __enter__(self):
900
+ return self
901
+
902
+ def __exit__(self, exc_type, exc_val, exc_tb):
903
+ self.close()