pydpm_xl 0.2.5rc2__py3-none-any.whl → 0.2.6__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.
@@ -6,7 +6,7 @@ This module provides a clean, abstracted interface for generating ASTs from DPM-
6
6
  without exposing internal complexity or version compatibility issues.
7
7
  """
8
8
 
9
- from typing import Dict, Any, Optional, List, Union
9
+ from typing import Dict, Any, Optional, List, Union, Tuple
10
10
  from pathlib import Path
11
11
  import json
12
12
  from datetime import datetime
@@ -139,24 +139,6 @@ class ASTGeneratorAPI:
139
139
  }
140
140
  }
141
141
 
142
- def parse_batch(self, expressions: List[str]) -> List[Dict[str, Any]]:
143
- """
144
- Parse multiple expressions efficiently.
145
-
146
- Args:
147
- expressions: List of DPM-XL expression strings
148
-
149
- Returns:
150
- List of parse results (same format as parse_expression)
151
- """
152
- results = []
153
- for i, expr in enumerate(expressions):
154
- result = self.parse_expression(expr)
155
- result['metadata']['batch_index'] = i
156
- results.append(result)
157
-
158
- return results
159
-
160
142
  def validate_expression(self, expression: str) -> Dict[str, Any]:
161
143
  """
162
144
  Validate expression syntax without full parsing.
@@ -333,43 +315,42 @@ class ASTGeneratorAPI:
333
315
  "data_populated": False,
334
316
  }
335
317
 
336
- def generate_complete_batch(
318
+ # ============================================================================
319
+ # Enriched AST Generation (requires database)
320
+ # ============================================================================
321
+
322
+ def _normalize_expressions_input(
337
323
  self,
338
- expressions: List[str],
339
- release_id: Optional[int] = None,
340
- ) -> List[Dict[str, Any]]:
324
+ expressions: Union[str, List[Tuple[str, str, Optional[str]]]]
325
+ ) -> List[Tuple[str, str, Optional[str]]]:
341
326
  """
342
- Generate complete ASTs for multiple expressions.
327
+ Normalize input to list of (expression, operation_code, precondition) tuples.
328
+
329
+ Supports:
330
+ - Single expression string: "expr" -> [("expr", "default_code", None)]
331
+ - List of tuples: [("expr1", "op1", "precond1"), ("expr2", "op2", None)]
343
332
 
344
333
  Args:
345
- expressions: List of DPM-XL expression strings
346
- release_id: Optional release ID to filter database lookups by specific release.
347
- If None, uses all available data (release-agnostic).
334
+ expressions: Either a single expression string or a list of tuples
348
335
 
349
336
  Returns:
350
- list: List of result dictionaries (same format as generate_complete_ast)
337
+ List of (expression, operation_code, precondition) tuples
351
338
  """
352
- results = []
353
- for i, expr in enumerate(expressions):
354
- result = self.generate_complete_ast(expr, release_id=release_id)
355
- result["batch_index"] = i
356
- results.append(result)
357
- return results
358
-
359
- # ============================================================================
360
- # Enriched AST Generation (requires database)
361
- # ============================================================================
339
+ if isinstance(expressions, str):
340
+ return [(expressions, "default_code", None)]
341
+ return expressions
362
342
 
363
343
  def generate_enriched_ast(
364
344
  self,
365
- expression: str,
366
- dpm_version: Optional[str] = None,
367
- operation_code: Optional[str] = None,
345
+ expressions: Union[str, List[Tuple[str, str, Optional[str]]]],
346
+ release_code: Optional[str] = None,
368
347
  table_context: Optional[Dict[str, Any]] = None,
369
- precondition: Optional[str] = None,
370
348
  release_id: Optional[int] = None,
371
349
  output_path: Optional[Union[str, Path]] = None,
372
350
  primary_module_vid: Optional[int] = None,
351
+ module_code: Optional[str] = None,
352
+ preferred_module_dependencies: Optional[List[str]] = None,
353
+ module_version_number: Optional[str] = None,
373
354
  ) -> Dict[str, Any]:
374
355
  """
375
356
  Generate enriched, engine-ready AST with framework structure (Level 3).
@@ -378,6 +359,9 @@ class ASTGeneratorAPI:
378
359
  framework structure with operations, variables, tables, and preconditions sections.
379
360
  This is the format required by business rule execution engines.
380
361
 
362
+ Supports both single expressions (for backward compatibility) and multiple
363
+ expression/operation/precondition tuples for generating scripts with multiple operations.
364
+
381
365
  **What you get:**
382
366
  - Everything from generate_complete_ast() PLUS:
383
367
  - Framework structure: operations, variables, tables, preconditions
@@ -392,13 +376,17 @@ class ASTGeneratorAPI:
392
376
  - Module exports with cross-module dependency tracking
393
377
 
394
378
  Args:
395
- expression: DPM-XL expression string
396
- dpm_version: DPM version code (e.g., "4.0", "4.1", "4.2")
397
- operation_code: Optional operation code (defaults to "default_code")
379
+ expressions: Either a single DPM-XL expression string (backward compatible),
380
+ or a list of tuples: [(expression, operation_code, precondition), ...].
381
+ Each tuple contains:
382
+ - expression (str): The DPM-XL expression (required)
383
+ - operation_code (str): The operation code (required)
384
+ - precondition (Optional[str]): Optional precondition reference (e.g., {v_F_44_04})
385
+ release_code: Optional release code (e.g., "4.0", "4.1", "4.2").
386
+ Mutually exclusive with release_id and module_version_number.
398
387
  table_context: Optional table context dict with keys: 'table', 'columns', 'rows', 'sheets', 'default', 'interval'
399
- precondition: Optional precondition variable reference (e.g., {v_F_44_04})
400
388
  release_id: Optional release ID to filter database lookups by specific release.
401
- If None, uses all available data (release-agnostic).
389
+ Mutually exclusive with release_code and module_version_number.
402
390
  output_path: Optional path (string or Path) to save the enriched_ast as JSON file.
403
391
  If provided, the enriched_ast will be automatically saved to this location.
404
392
  primary_module_vid: Optional module version ID of the module being exported.
@@ -406,6 +394,18 @@ class ASTGeneratorAPI:
406
394
  other modules will be identified and added to dependency_modules and
407
395
  cross_instance_dependencies fields. If None, cross-module detection uses
408
396
  the first table's module as the primary module.
397
+ module_code: Optional module code (e.g., "FINREP9") to specify the main module.
398
+ The main module's URL will be used as the root key of the output.
399
+ If provided, this takes precedence over primary_module_vid for determining
400
+ the main module.
401
+ preferred_module_dependencies: Optional list of module codes to prefer when
402
+ multiple dependency scopes are possible. If a table belongs to multiple modules,
403
+ the module in this list will be selected as the dependency.
404
+ module_version_number: Optional module version number (e.g., "4.1.0") to specify
405
+ which version of the module to use. Requires module_code to be specified.
406
+ Mutually exclusive with release_code and release_id.
407
+ If none of release_code, release_id, or module_version_number are provided,
408
+ the latest (active) module version is used.
409
409
 
410
410
  Returns:
411
411
  dict: {
@@ -414,51 +414,68 @@ class ASTGeneratorAPI:
414
414
  'error': str # Error message if failed
415
415
  }
416
416
 
417
+ Raises:
418
+ ValueError: If more than one of release_id, release_code, or module_version_number
419
+ are specified; if module_version_number is specified without module_code; or if
420
+ no operation scope belongs to the specified module.
421
+
417
422
  Example:
418
423
  >>> generator = ASTGeneratorAPI(database_path="data.db")
424
+ >>> # Single expression (backward compatible)
419
425
  >>> result = generator.generate_enriched_ast(
420
426
  ... "{tF_01.00, r0010, c0010}",
421
- ... dpm_version="4.2",
422
- ... operation_code="my_validation"
427
+ ... release_code="4.2",
423
428
  ... )
424
- >>> # result['enriched_ast'] contains framework structure ready for engines
425
429
  >>>
426
- >>> # For module exports with cross-module dependency tracking:
430
+ >>> # Multiple expressions with operations and preconditions
427
431
  >>> result = generator.generate_enriched_ast(
428
- ... "{tC_26.00, r030, c010} * {tC_01.00, r0015, c0010}",
429
- ... dpm_version="4.2",
430
- ... operation_code="v2814_m",
431
- ... primary_module_vid=123, # Module being exported
432
- ... release_id=42
432
+ ... [
433
+ ... ("{tF_01.00, r0010, c0010} = 0", "v1234_m", None),
434
+ ... ("{tF_01.00, r0020, c0010} > 0", "v1235_m", "{v_F_44_04}"),
435
+ ... ("{tF_01.00, r0030, c0010} >= 0", "v1236_m", "{v_F_44_04}"), # Same precondition, deduplicated
436
+ ... ],
437
+ ... release_code="4.2",
438
+ ... module_code="FINREP9",
433
439
  ... )
434
- >>> # result['enriched_ast']['dependency_modules'] contains external module info
435
- >>> # result['enriched_ast']['dependency_information']['cross_instance_dependencies']
436
- >>> # contains list of external module dependencies
437
440
  """
438
- try:
439
- # Generate complete AST first
440
- complete_result = self.generate_complete_ast(expression, release_id=release_id)
441
+ # Validate mutually exclusive parameters
442
+ version_params = [release_id, release_code, module_version_number]
443
+ if sum(p is not None for p in version_params) > 1:
444
+ raise ValueError(
445
+ "Specify a maximum of one of release_id, release_code, or module_version_number."
446
+ )
441
447
 
442
- if not complete_result["success"]:
443
- return {
444
- "success": False,
445
- "enriched_ast": None,
446
- "error": f"Failed to generate complete AST: {complete_result['error']}",
447
- }
448
+ # Validate module_version_number requires module_code
449
+ if module_version_number is not None and module_code is None:
450
+ raise ValueError(
451
+ "module_version_number requires module_code to be specified."
452
+ )
448
453
 
449
- complete_ast = complete_result["ast"]
450
- context = complete_result.get("context") or table_context
454
+ # Resolve version parameters to release_id
455
+ effective_release_id = release_id
456
+ effective_release_code = release_code
451
457
 
452
- # Enrich with framework structure
453
- enriched_ast = self._enrich_ast_with_metadata(
454
- ast_dict=complete_ast,
455
- expression=expression,
456
- context=context,
457
- dpm_version=dpm_version,
458
- operation_code=operation_code,
459
- precondition=precondition,
460
- release_id=release_id,
458
+ if release_code is not None:
459
+ effective_release_id = self._resolve_release_code(release_code)
460
+ elif module_version_number is not None:
461
+ # Resolve module_version_number to release_id
462
+ effective_release_id, effective_release_code = self._resolve_module_version(
463
+ module_code, module_version_number
464
+ )
465
+
466
+ # Normalize input to list of tuples
467
+ expression_tuples = self._normalize_expressions_input(expressions)
468
+
469
+ try:
470
+ # Enrich with framework structure for multiple expressions
471
+ enriched_ast = self._enrich_ast_with_metadata_multi(
472
+ expression_tuples=expression_tuples,
473
+ table_context=table_context,
474
+ release_code=effective_release_code,
475
+ release_id=effective_release_id,
461
476
  primary_module_vid=primary_module_vid,
477
+ module_code=module_code,
478
+ preferred_module_dependencies=preferred_module_dependencies,
462
479
  )
463
480
 
464
481
  # Save to file if output_path is provided
@@ -732,11 +749,13 @@ class ASTGeneratorAPI:
732
749
  ast_dict: Dict[str, Any],
733
750
  expression: str,
734
751
  context: Optional[Dict[str, Any]],
735
- dpm_version: Optional[str] = None,
752
+ release_code: Optional[str] = None,
736
753
  operation_code: Optional[str] = None,
737
754
  precondition: Optional[str] = None,
738
755
  release_id: Optional[int] = None,
739
756
  primary_module_vid: Optional[int] = None,
757
+ module_code: Optional[str] = None,
758
+ preferred_module_dependencies: Optional[List[str]] = None,
740
759
  ) -> Dict[str, Any]:
741
760
  """
742
761
  Add framework structure (operations, variables, tables, preconditions) to complete AST.
@@ -747,7 +766,7 @@ class ASTGeneratorAPI:
747
766
  ast_dict: Complete AST dictionary
748
767
  expression: Original DPM-XL expression
749
768
  context: Context dict with table, rows, columns, sheets, default, interval
750
- dpm_version: DPM version code (e.g., "4.2")
769
+ release_code: Release code (e.g., "4.2")
751
770
  operation_code: Operation code (defaults to "default_code")
752
771
  precondition: Precondition variable reference (e.g., {v_F_44_04})
753
772
  release_id: Optional release ID to filter database lookups
@@ -766,25 +785,38 @@ class ASTGeneratorAPI:
766
785
  # Get current date for framework structure
767
786
  current_date = datetime.now().strftime("%Y-%m-%d")
768
787
 
788
+ # Detect primary module from the expression (or use provided module_code)
789
+ primary_module_info = self._get_primary_module_info(
790
+ expression=expression,
791
+ primary_module_vid=primary_module_vid,
792
+ release_id=release_id,
793
+ module_code=module_code,
794
+ )
795
+
769
796
  # Query database for release information
770
- release_info = self._get_release_info(dpm_version, engine)
797
+ release_info = self._get_release_info(release_code, engine)
771
798
 
772
- # Build module info
799
+ # Build module info using detected primary module or defaults
773
800
  module_info = {
774
- "module_code": "default",
775
- "module_version": "1.0.0",
776
- "framework_code": "default",
801
+ "module_code": primary_module_info.get("module_code", "default"),
802
+ "module_version": primary_module_info.get("module_version", "1.0.0"),
803
+ "framework_code": primary_module_info.get("framework_code", "default"),
777
804
  "dpm_release": {
778
805
  "release": release_info["release"],
779
806
  "publication_date": release_info["publication_date"],
780
807
  },
781
- "dates": {"from": "2001-01-01", "to": None},
808
+ "dates": {
809
+ "from": primary_module_info.get("from_date", "2001-01-01"),
810
+ "to": primary_module_info.get("to_date"),
811
+ },
782
812
  }
783
813
 
784
814
  # Add coordinates to AST data entries
785
815
  ast_with_coords = self._add_coordinates_to_ast(ast_dict, context)
786
816
 
787
817
  # Build operations section
818
+ # Use module's from_date for from_submission_date (fallback to current date)
819
+ submission_date = primary_module_info.get("from_date", current_date)
788
820
  operations = {
789
821
  operation_code: {
790
822
  "version_id": hash(expression) % 10000,
@@ -792,56 +824,118 @@ class ASTGeneratorAPI:
792
824
  "expression": expression,
793
825
  "root_operator_id": 24, # Default for now
794
826
  "ast": ast_with_coords,
795
- "from_submission_date": current_date,
827
+ "from_submission_date": submission_date,
796
828
  "severity": "Error",
797
829
  }
798
830
  }
799
831
 
800
832
  # Build variables section by extracting from the complete AST
801
- all_variables, variables_by_table = self._extract_variables_from_ast(ast_with_coords)
833
+ # This gives us the tables referenced in the expression
834
+ _, variables_by_table = self._extract_variables_from_ast(ast_with_coords)
835
+
836
+ # Clean extra fields from data entries (after extraction, as it uses data_type)
837
+ self._clean_ast_data_entries(ast_with_coords)
802
838
 
803
- variables = all_variables
839
+ all_variables = {}
804
840
  tables = {}
805
841
 
806
- # Build tables with their specific variables
807
- for table_code, table_variables in variables_by_table.items():
808
- tables[table_code] = {"variables": table_variables, "open_keys": {}}
842
+ # Get tables_with_modules to filter tables by primary module
843
+ tables_with_modules = primary_module_info.get("tables_with_modules", [])
844
+ primary_module_vid = primary_module_info.get("module_vid")
845
+
846
+ # Build mapping of table_code -> module_vid for filtering
847
+ table_to_module = {}
848
+ for table_info in tables_with_modules:
849
+ table_code = table_info.get("code", "")
850
+ module_vid = table_info.get("module_vid")
851
+ if table_code and module_vid:
852
+ table_to_module[table_code] = module_vid
853
+
854
+ # Initialize DataDictionaryAPI to query open keys and all variables
855
+ from py_dpm.api.dpm import DataDictionaryAPI
856
+ data_dict_api = DataDictionaryAPI(
857
+ database_path=self.database_path,
858
+ connection_url=self.connection_url
859
+ )
860
+
861
+ # Build tables with ALL variables from database (not just from expression)
862
+ # Only include tables belonging to the primary module
863
+ for table_code in variables_by_table.keys():
864
+ # Check if this table belongs to the primary module
865
+ table_module_vid = table_to_module.get(table_code)
866
+ if table_module_vid and table_module_vid != primary_module_vid:
867
+ # This table belongs to a different module, skip it for the main tables section
868
+ continue
869
+
870
+ # Get table version info to get table_vid
871
+ table_info = data_dict_api.get_table_version(table_code, release_id)
872
+
873
+ if table_info and table_info.get("table_vid"):
874
+ table_vid = table_info["table_vid"]
875
+ # Get ALL variables for this table from database
876
+ table_variables = data_dict_api.get_all_variables_for_table(table_vid)
877
+ else:
878
+ # Fallback to expression variables if table not found
879
+ table_variables = variables_by_table[table_code]
880
+
881
+ # Query open keys for this table
882
+ open_keys_list = data_dict_api.get_open_keys_for_table(table_code, release_id)
883
+ open_keys = {item["property_code"]: item["data_type_code"] for item in open_keys_list}
884
+
885
+ tables[table_code] = {"variables": table_variables, "open_keys": open_keys}
886
+
887
+ # Add table variables to all_variables
888
+ all_variables.update(table_variables)
889
+
890
+ data_dict_api.close()
809
891
 
810
892
  # Build preconditions
811
893
  preconditions = {}
812
894
  precondition_variables = {}
813
895
 
814
- if precondition or (context and "table" in context):
896
+ if precondition:
815
897
  preconditions, precondition_variables = self._build_preconditions(
816
898
  precondition=precondition,
817
899
  context=context,
818
900
  operation_code=operation_code,
819
- engine=engine,
901
+ release_id=release_id,
820
902
  )
821
903
 
822
904
  # Detect cross-module dependencies
905
+ # Use ALL variables from tables (not just expression variables)
906
+ full_variables_by_table = {
907
+ table_code: table_data["variables"]
908
+ for table_code, table_data in tables.items()
909
+ }
910
+ # Use module_vid from primary_module_info (may have been resolved from module_code)
911
+ resolved_primary_module_vid = primary_module_info.get("module_vid") or primary_module_vid
823
912
  dependency_modules, cross_instance_dependencies = self._detect_cross_module_dependencies(
824
913
  expression=expression,
825
- variables_by_table=variables_by_table,
826
- primary_module_vid=primary_module_vid,
914
+ variables_by_table=full_variables_by_table,
915
+ primary_module_vid=resolved_primary_module_vid,
827
916
  operation_code=operation_code,
828
917
  release_id=release_id,
918
+ preferred_module_dependencies=preferred_module_dependencies,
829
919
  )
830
920
 
831
921
  # Build dependency information
922
+ # intra_instance_validations should be empty for cross-module operations
923
+ # (operations that have cross_instance_dependencies)
924
+ is_cross_module = bool(cross_instance_dependencies)
832
925
  dependency_info = {
833
- "intra_instance_validations": [operation_code],
926
+ "intra_instance_validations": [] if is_cross_module else [operation_code],
834
927
  "cross_instance_dependencies": cross_instance_dependencies,
835
928
  }
836
929
 
837
930
  # Build complete structure
838
- namespace = "default_module"
931
+ # Use module URI as namespace if available, otherwise use "default_module"
932
+ namespace = primary_module_info.get("module_uri", "default_module")
839
933
 
840
934
  return {
841
935
  namespace: {
842
936
  **module_info,
843
937
  "operations": operations,
844
- "variables": variables,
938
+ "variables": all_variables,
845
939
  "tables": tables,
846
940
  "preconditions": preconditions,
847
941
  "precondition_variables": precondition_variables,
@@ -850,32 +944,591 @@ class ASTGeneratorAPI:
850
944
  }
851
945
  }
852
946
 
853
- def _get_release_info(self, dpm_version: Optional[str], engine) -> Dict[str, Any]:
947
+ def _enrich_ast_with_metadata_multi(
948
+ self,
949
+ expression_tuples: List[Tuple[str, str, Optional[str]]],
950
+ table_context: Optional[Dict[str, Any]],
951
+ release_code: Optional[str] = None,
952
+ release_id: Optional[int] = None,
953
+ primary_module_vid: Optional[int] = None,
954
+ module_code: Optional[str] = None,
955
+ preferred_module_dependencies: Optional[List[str]] = None,
956
+ ) -> Dict[str, Any]:
957
+ """
958
+ Add framework structure for multiple expressions (operations, variables, tables, preconditions).
959
+
960
+ This creates the engine-ready format with all metadata sections, aggregating
961
+ multiple expressions into a single script structure.
962
+
963
+ Args:
964
+ expression_tuples: List of (expression, operation_code, precondition) tuples
965
+ table_context: Context dict with table, rows, columns, sheets, default, interval
966
+ release_code: Release code (e.g., "4.2")
967
+ release_id: Optional release ID to filter database lookups
968
+ primary_module_vid: Module VID being exported (to identify external dependencies)
969
+ module_code: Optional module code to specify the main module
970
+ preferred_module_dependencies: Optional list of module codes to prefer for dependencies
971
+
972
+ Returns:
973
+ Dict with the enriched AST structure
974
+
975
+ Raises:
976
+ ValueError: If no operation scope belongs to the specified module
977
+ """
978
+ from py_dpm.dpm.utils import get_engine
979
+ from py_dpm.api.dpm import DataDictionaryAPI
980
+
981
+ # Initialize database connection
982
+ engine = get_engine(database_path=self.database_path, connection_url=self.connection_url)
983
+
984
+ # Get current date for framework structure
985
+ current_date = datetime.now().strftime("%Y-%m-%d")
986
+
987
+ # Aggregated structures
988
+ all_operations = {}
989
+ all_variables = {}
990
+ all_tables = {}
991
+ all_preconditions = {}
992
+ all_precondition_variables = {}
993
+ all_dependency_modules = {}
994
+ all_cross_instance_deps = []
995
+ all_intra_instance_ops = []
996
+
997
+ # Track processed preconditions to avoid duplicates
998
+ # Maps precondition string -> list of precondition keys generated from it
999
+ processed_preconditions: Dict[str, List[str]] = {}
1000
+
1001
+ # Track all tables with their modules for validation
1002
+ all_tables_with_modules = []
1003
+
1004
+ # Flag to track if at least one operation belongs to the primary module
1005
+ has_primary_module_operation = False
1006
+
1007
+ # Initialize DataDictionaryAPI once for all expressions
1008
+ data_dict_api = DataDictionaryAPI(
1009
+ database_path=self.database_path,
1010
+ connection_url=self.connection_url
1011
+ )
1012
+
1013
+ # Primary module info will be determined from the first expression or module_code
1014
+ primary_module_info = None
1015
+ namespace = None
1016
+
1017
+ try:
1018
+ for idx, (expression, operation_code, precondition) in enumerate(expression_tuples):
1019
+ # Generate complete AST for this expression
1020
+ complete_result = self.generate_complete_ast(expression, release_id=release_id)
1021
+
1022
+ if not complete_result["success"]:
1023
+ raise ValueError(
1024
+ f"Failed to generate complete AST for expression {idx + 1} "
1025
+ f"(operation '{operation_code}'): {complete_result['error']}"
1026
+ )
1027
+
1028
+ complete_ast = complete_result["ast"]
1029
+ context = complete_result.get("context") or table_context
1030
+
1031
+ # Get primary module info from first expression (or use module_code)
1032
+ if primary_module_info is None:
1033
+ primary_module_info = self._get_primary_module_info(
1034
+ expression=expression,
1035
+ primary_module_vid=primary_module_vid,
1036
+ release_id=release_id,
1037
+ module_code=module_code,
1038
+ )
1039
+ namespace = primary_module_info.get("module_uri", "default_module")
1040
+
1041
+ # Add coordinates to AST data entries
1042
+ ast_with_coords = self._add_coordinates_to_ast(complete_ast, context)
1043
+
1044
+ # Build operation entry
1045
+ submission_date = primary_module_info.get("from_date", current_date)
1046
+ all_operations[operation_code] = {
1047
+ "version_id": hash(expression) % 10000,
1048
+ "code": operation_code,
1049
+ "expression": expression,
1050
+ "root_operator_id": 24,
1051
+ "ast": ast_with_coords,
1052
+ "from_submission_date": submission_date,
1053
+ "severity": "Error",
1054
+ }
1055
+
1056
+ # Extract variables from this expression's AST
1057
+ _, variables_by_table = self._extract_variables_from_ast(ast_with_coords)
1058
+
1059
+ # Clean extra fields from data entries
1060
+ self._clean_ast_data_entries(ast_with_coords)
1061
+
1062
+ # Get tables with modules for this expression
1063
+ from py_dpm.api.dpm_xl.operation_scopes import OperationScopesAPI
1064
+ scopes_api = OperationScopesAPI(
1065
+ database_path=self.database_path,
1066
+ connection_url=self.connection_url
1067
+ )
1068
+ tables_with_modules = scopes_api.get_tables_with_metadata_from_expression(
1069
+ expression=expression,
1070
+ release_id=release_id
1071
+ )
1072
+ all_tables_with_modules.extend(tables_with_modules)
1073
+
1074
+ # Build mapping of table_code -> module_vid
1075
+ # Prefer the module VID that matches the detected primary module
1076
+ table_to_module = {}
1077
+ primary_module_code = primary_module_info.get("module_code")
1078
+
1079
+ # First pass: record mappings for tables belonging to the primary module (by code)
1080
+ if primary_module_code:
1081
+ for table_info in tables_with_modules:
1082
+ table_code = table_info.get("code", "")
1083
+ table_module_vid = table_info.get("module_vid")
1084
+ table_module_code = table_info.get("module_code")
1085
+ if (
1086
+ table_code
1087
+ and table_module_vid
1088
+ and table_module_code == primary_module_code
1089
+ ):
1090
+ table_to_module[table_code] = table_module_vid
1091
+
1092
+ # Second pass: fill in any remaining tables with the first available module VID
1093
+ for table_info in tables_with_modules:
1094
+ table_code = table_info.get("code", "")
1095
+ table_module_vid = table_info.get("module_vid")
1096
+ if table_code and table_module_vid and table_code not in table_to_module:
1097
+ table_to_module[table_code] = table_module_vid
1098
+
1099
+ resolved_primary_module_vid = primary_module_info.get("module_vid") or primary_module_vid
1100
+
1101
+ # Process tables from this expression
1102
+ for table_code in variables_by_table.keys():
1103
+ # Check if this table belongs to the primary module
1104
+ table_module_vid = table_to_module.get(table_code)
1105
+
1106
+ if table_module_vid and table_module_vid != resolved_primary_module_vid:
1107
+ # This table belongs to a different module, skip for main tables
1108
+ continue
1109
+
1110
+ # Skip if we already have this table
1111
+ if table_code in all_tables:
1112
+ # Table already added, it passed the module filter before
1113
+ has_primary_module_operation = True
1114
+ continue
1115
+
1116
+ # Get table version info
1117
+ table_info = data_dict_api.get_table_version(table_code, release_id)
1118
+
1119
+ if table_info and table_info.get("table_vid"):
1120
+ table_vid = table_info["table_vid"]
1121
+ table_variables = data_dict_api.get_all_variables_for_table(table_vid)
1122
+ else:
1123
+ table_variables = variables_by_table[table_code]
1124
+
1125
+ # Query open keys for this table
1126
+ open_keys_list = data_dict_api.get_open_keys_for_table(table_code, release_id)
1127
+ open_keys = {item["property_code"]: item["data_type_code"] for item in open_keys_list}
1128
+
1129
+ all_tables[table_code] = {"variables": table_variables, "open_keys": open_keys}
1130
+ all_variables.update(table_variables)
1131
+
1132
+ # We successfully added a table that passed the module filter
1133
+ # This means at least one operation references the primary module
1134
+ has_primary_module_operation = True
1135
+
1136
+ # Handle precondition (deduplicate by precondition string)
1137
+ if precondition and precondition not in processed_preconditions:
1138
+ preconds, precond_vars = self._build_preconditions(
1139
+ precondition=precondition,
1140
+ context=context,
1141
+ operation_code=operation_code,
1142
+ release_id=release_id,
1143
+ )
1144
+ # Track which keys were generated for this precondition string
1145
+ processed_preconditions[precondition] = list(preconds.keys())
1146
+ # Merge preconditions
1147
+ for precond_key, precond_data in preconds.items():
1148
+ if precond_key not in all_preconditions:
1149
+ all_preconditions[precond_key] = precond_data
1150
+ else:
1151
+ # Add this operation to affected_operations if not already there
1152
+ if operation_code not in all_preconditions[precond_key]["affected_operations"]:
1153
+ all_preconditions[precond_key]["affected_operations"].append(operation_code)
1154
+ all_precondition_variables.update(precond_vars)
1155
+ elif precondition and precondition in processed_preconditions:
1156
+ # Precondition already processed, add this operation ONLY to the matching precondition(s)
1157
+ matching_keys = processed_preconditions[precondition]
1158
+ for precond_key in matching_keys:
1159
+ if precond_key in all_preconditions:
1160
+ if operation_code not in all_preconditions[precond_key]["affected_operations"]:
1161
+ all_preconditions[precond_key]["affected_operations"].append(operation_code)
1162
+
1163
+ # Detect cross-module dependencies for this expression
1164
+ full_variables_by_table = {
1165
+ table_code: table_data["variables"]
1166
+ for table_code, table_data in all_tables.items()
1167
+ }
1168
+ dep_modules, cross_deps = self._detect_cross_module_dependencies(
1169
+ expression=expression,
1170
+ variables_by_table=full_variables_by_table,
1171
+ primary_module_vid=resolved_primary_module_vid,
1172
+ operation_code=operation_code,
1173
+ release_id=release_id,
1174
+ preferred_module_dependencies=preferred_module_dependencies,
1175
+ )
1176
+
1177
+ # Merge dependency modules (avoid table duplicates)
1178
+ self._merge_dependency_modules(all_dependency_modules, dep_modules)
1179
+
1180
+ # Merge cross-instance dependencies (avoid duplicates)
1181
+ self._merge_cross_instance_dependencies(all_cross_instance_deps, cross_deps)
1182
+
1183
+ # Track intra-instance operations
1184
+ if not cross_deps:
1185
+ all_intra_instance_ops.append(operation_code)
1186
+
1187
+ finally:
1188
+ data_dict_api.close()
1189
+
1190
+ # Validate: at least one operation must belong to the primary module
1191
+ if not has_primary_module_operation and module_code:
1192
+ raise ValueError(
1193
+ f"No operation scope belongs to the specified module '{module_code}'. "
1194
+ "At least one expression must reference tables from the primary module."
1195
+ )
1196
+
1197
+ # Query database for release information
1198
+ release_info = self._get_release_info(release_code, engine)
1199
+
1200
+ # Build module info
1201
+ module_info = {
1202
+ "module_code": primary_module_info.get("module_code", "default"),
1203
+ "module_version": primary_module_info.get("module_version", "1.0.0"),
1204
+ "framework_code": primary_module_info.get("framework_code", "default"),
1205
+ "dpm_release": {
1206
+ "release": release_info["release"],
1207
+ "publication_date": release_info["publication_date"],
1208
+ },
1209
+ "dates": {
1210
+ "from": primary_module_info.get("from_date", "2001-01-01"),
1211
+ "to": primary_module_info.get("to_date"),
1212
+ },
1213
+ }
1214
+
1215
+ # Build dependency information
1216
+ dependency_info = {
1217
+ "intra_instance_validations": all_intra_instance_ops,
1218
+ "cross_instance_dependencies": all_cross_instance_deps,
1219
+ }
1220
+
1221
+ return {
1222
+ namespace: {
1223
+ **module_info,
1224
+ "operations": all_operations,
1225
+ "variables": all_variables,
1226
+ "tables": all_tables,
1227
+ "preconditions": all_preconditions,
1228
+ "precondition_variables": all_precondition_variables,
1229
+ "dependency_information": dependency_info,
1230
+ "dependency_modules": all_dependency_modules,
1231
+ }
1232
+ }
1233
+
1234
+ def _merge_dependency_modules(
1235
+ self,
1236
+ existing: Dict[str, Any],
1237
+ new: Dict[str, Any]
1238
+ ) -> None:
1239
+ """
1240
+ Merge new dependency_modules into existing, avoiding table duplicates.
1241
+
1242
+ Args:
1243
+ existing: Existing dependency_modules dict (modified in place)
1244
+ new: New dependency_modules dict to merge
1245
+ """
1246
+ for uri, module_data in new.items():
1247
+ if uri not in existing:
1248
+ existing[uri] = module_data
1249
+ else:
1250
+ # Merge tables (avoid duplicates)
1251
+ for table_code, table_data in module_data.get("tables", {}).items():
1252
+ if table_code not in existing[uri].get("tables", {}):
1253
+ existing[uri].setdefault("tables", {})[table_code] = table_data
1254
+ # Merge variables
1255
+ existing[uri].setdefault("variables", {}).update(
1256
+ module_data.get("variables", {})
1257
+ )
1258
+
1259
+ def _merge_cross_instance_dependencies(
1260
+ self,
1261
+ existing: List[Dict[str, Any]],
1262
+ new: List[Dict[str, Any]]
1263
+ ) -> None:
1264
+ """
1265
+ Merge new cross_instance_dependencies into existing, avoiding duplicates.
1266
+
1267
+ Duplicates are identified by the set of module URIs involved.
1268
+
1269
+ Args:
1270
+ existing: Existing list (modified in place)
1271
+ new: New list to merge
1272
+ """
1273
+ def get_module_uris(dep: Dict[str, Any]) -> tuple:
1274
+ """Extract sorted URIs from modules list for deduplication."""
1275
+ modules = dep.get("modules", [])
1276
+ uris = []
1277
+ for m in modules:
1278
+ if isinstance(m, dict):
1279
+ uris.append(m.get("URI", ""))
1280
+ else:
1281
+ uris.append(str(m))
1282
+ return tuple(sorted(uris))
1283
+
1284
+ # Build a set of existing module URI combinations for deduplication
1285
+ existing_module_sets = set()
1286
+ for dep in existing:
1287
+ existing_module_sets.add(get_module_uris(dep))
1288
+
1289
+ for dep in new:
1290
+ dep_uris = get_module_uris(dep)
1291
+ if dep_uris not in existing_module_sets:
1292
+ existing.append(dep)
1293
+ existing_module_sets.add(dep_uris)
1294
+ else:
1295
+ # Merge affected_operations for existing dependency
1296
+ for existing_dep in existing:
1297
+ if get_module_uris(existing_dep) == dep_uris:
1298
+ for op in dep.get("affected_operations", []):
1299
+ if op not in existing_dep.get("affected_operations", []):
1300
+ existing_dep.setdefault("affected_operations", []).append(op)
1301
+ break
1302
+
1303
+ def _get_primary_module_info(
1304
+ self,
1305
+ expression: str,
1306
+ primary_module_vid: Optional[int],
1307
+ release_id: Optional[int],
1308
+ module_code: Optional[str] = None,
1309
+ ) -> Dict[str, Any]:
1310
+ """
1311
+ Detect and return metadata for the primary module from the expression.
1312
+
1313
+ Args:
1314
+ expression: DPM-XL expression
1315
+ primary_module_vid: Optional module VID (if known)
1316
+ release_id: Optional release ID for filtering
1317
+ module_code: Optional module code (e.g., "FINREP9") - takes precedence over
1318
+ primary_module_vid if provided
1319
+
1320
+ Returns:
1321
+ Dict with module_uri, module_code, module_version, framework_code,
1322
+ from_date, to_date, module_vid
1323
+ """
1324
+ from py_dpm.api.dpm_xl.operation_scopes import OperationScopesAPI
1325
+ from py_dpm.dpm.queries.explorer_queries import ExplorerQuery
1326
+
1327
+ default_info = {
1328
+ "module_uri": "default_module",
1329
+ "module_code": "default",
1330
+ "module_version": "1.0.0",
1331
+ "framework_code": "default",
1332
+ "from_date": "2001-01-01",
1333
+ "to_date": None,
1334
+ "module_vid": None,
1335
+ }
1336
+
1337
+ try:
1338
+ scopes_api = OperationScopesAPI(
1339
+ database_path=self.database_path,
1340
+ connection_url=self.connection_url
1341
+ )
1342
+
1343
+ # Get tables with module metadata from expression
1344
+ tables_with_modules = scopes_api.get_tables_with_metadata_from_expression(
1345
+ expression=expression,
1346
+ release_id=release_id
1347
+ )
1348
+
1349
+ if not tables_with_modules:
1350
+ scopes_api.close()
1351
+ return default_info
1352
+
1353
+ # Determine primary module
1354
+ # Priority: module_code (param) > primary_module_vid > first table
1355
+ primary_table = None
1356
+
1357
+ if module_code:
1358
+ # Find table matching the provided module_code
1359
+ for table_info in tables_with_modules:
1360
+ if table_info.get("module_code") == module_code:
1361
+ primary_table = table_info
1362
+ break
1363
+ elif primary_module_vid:
1364
+ # Find table matching the provided module VID
1365
+ for table_info in tables_with_modules:
1366
+ if table_info.get("module_vid") == primary_module_vid:
1367
+ primary_table = table_info
1368
+ break
1369
+
1370
+ # If no match found, use first table
1371
+ if not primary_table:
1372
+ primary_table = tables_with_modules[0]
1373
+
1374
+ resolved_module_code = primary_table.get("module_code")
1375
+ module_vid = primary_table.get("module_vid")
1376
+
1377
+ # Get module URI
1378
+ try:
1379
+ module_uri = ExplorerQuery.get_module_url(
1380
+ scopes_api.session,
1381
+ module_code=resolved_module_code,
1382
+ release_id=release_id,
1383
+ )
1384
+ # Remove .json extension if present
1385
+ if module_uri and module_uri.endswith(".json"):
1386
+ module_uri = module_uri[:-5]
1387
+ except Exception:
1388
+ module_uri = "default_module"
1389
+
1390
+ # Get module version dates from scopes metadata
1391
+ from_date = "2001-01-01"
1392
+ to_date = None
1393
+ scopes_metadata = scopes_api.get_scopes_with_metadata_from_expression(
1394
+ expression=expression,
1395
+ release_id=release_id
1396
+ )
1397
+ for scope_info in scopes_metadata:
1398
+ for module in scope_info.module_versions:
1399
+ if module.get("module_vid") == module_vid:
1400
+ from_date = module.get("from_reference_date", from_date)
1401
+ to_date = module.get("to_reference_date", to_date)
1402
+ break
1403
+
1404
+ scopes_api.close()
1405
+
1406
+ return {
1407
+ "module_uri": module_uri or "default_module",
1408
+ "module_code": resolved_module_code or "default",
1409
+ "module_version": primary_table.get("module_version", "1.0.0"),
1410
+ "framework_code": resolved_module_code or "default", # Framework code typically matches module code
1411
+ "from_date": str(from_date) if from_date else "2001-01-01",
1412
+ "to_date": str(to_date) if to_date else None,
1413
+ "module_vid": module_vid,
1414
+ "tables_with_modules": tables_with_modules, # Include table-to-module mapping
1415
+ }
1416
+
1417
+ except Exception as e:
1418
+ import logging
1419
+ logging.warning(f"Failed to detect primary module info: {e}")
1420
+ return {**default_info, "tables_with_modules": []}
1421
+
1422
+ def _resolve_release_code(self, release_code: str) -> Optional[int]:
1423
+ """
1424
+ Resolve a release code (e.g., "4.2") to its release ID.
1425
+
1426
+ Args:
1427
+ release_code: The release code string (e.g., "4.2")
1428
+
1429
+ Returns:
1430
+ The release ID if found, None otherwise.
1431
+ """
1432
+ from py_dpm.dpm.utils import get_engine
1433
+ from py_dpm.dpm.models import Release
1434
+ from sqlalchemy.orm import sessionmaker
1435
+
1436
+ engine = get_engine(database_path=self.database_path, connection_url=self.connection_url)
1437
+ Session = sessionmaker(bind=engine)
1438
+ session = Session()
1439
+
1440
+ try:
1441
+ release = (
1442
+ session.query(Release)
1443
+ .filter(Release.code == release_code)
1444
+ .first()
1445
+ )
1446
+ if release:
1447
+ return release.releaseid
1448
+ return None
1449
+ except Exception:
1450
+ return None
1451
+ finally:
1452
+ session.close()
1453
+
1454
+ def _resolve_module_version(
1455
+ self, module_code: str, module_version_number: str
1456
+ ) -> Tuple[Optional[int], Optional[str]]:
1457
+ """
1458
+ Resolve a module version number to its release ID and release code.
1459
+
1460
+ Args:
1461
+ module_code: The module code (e.g., "COREP_LR")
1462
+ module_version_number: The module version number (e.g., "4.1.0")
1463
+
1464
+ Returns:
1465
+ Tuple of (release_id, release_code) if found, (None, None) otherwise.
1466
+ """
1467
+ from py_dpm.dpm.utils import get_engine
1468
+ from py_dpm.dpm.models import ModuleVersion, Release
1469
+ from sqlalchemy.orm import sessionmaker
1470
+
1471
+ engine = get_engine(database_path=self.database_path, connection_url=self.connection_url)
1472
+ Session = sessionmaker(bind=engine)
1473
+ session = Session()
1474
+
1475
+ try:
1476
+ # Find the module version by code and version number
1477
+ module_version = (
1478
+ session.query(ModuleVersion)
1479
+ .filter(
1480
+ ModuleVersion.code == module_code,
1481
+ ModuleVersion.versionnumber == module_version_number
1482
+ )
1483
+ .first()
1484
+ )
1485
+ if not module_version:
1486
+ raise ValueError(
1487
+ f"Module version '{module_version_number}' not found for module '{module_code}'."
1488
+ )
1489
+
1490
+ # Get the release code from the start release
1491
+ release = (
1492
+ session.query(Release)
1493
+ .filter(Release.releaseid == module_version.startreleaseid)
1494
+ .first()
1495
+ )
1496
+ release_code = release.code if release else None
1497
+
1498
+ return module_version.startreleaseid, release_code
1499
+ finally:
1500
+ session.close()
1501
+
1502
+ def _get_release_info(self, release_code: Optional[str], engine) -> Dict[str, Any]:
854
1503
  """Get release information from database using SQLAlchemy."""
855
1504
  from py_dpm.dpm.models import Release
856
1505
  from sqlalchemy.orm import sessionmaker
857
1506
 
1507
+ def format_date(date_value) -> str:
1508
+ """Format date whether it's a string or datetime object."""
1509
+ if date_value is None:
1510
+ return "2001-01-01"
1511
+ if isinstance(date_value, str):
1512
+ return date_value
1513
+ # Assume it's a datetime-like object
1514
+ return date_value.strftime("%Y-%m-%d")
1515
+
858
1516
  Session = sessionmaker(bind=engine)
859
1517
  session = Session()
860
1518
 
861
1519
  try:
862
- if dpm_version:
1520
+ if release_code:
863
1521
  # Query for specific version
864
- version_float = float(dpm_version)
865
1522
  release = (
866
1523
  session.query(Release)
867
- .filter(Release.code == str(version_float))
1524
+ .filter(Release.code == release_code)
868
1525
  .first()
869
1526
  )
870
1527
 
871
1528
  if release:
872
1529
  return {
873
- "release": str(release.code) if release.code else dpm_version,
874
- "publication_date": (
875
- release.date.strftime("%Y-%m-%d")
876
- if release.date
877
- else "2001-01-01"
878
- ),
1530
+ "release": str(release.code) if release.code else release_code,
1531
+ "publication_date": format_date(release.date),
879
1532
  }
880
1533
 
881
1534
  # Fallback: get latest released version
@@ -889,9 +1542,7 @@ class ASTGeneratorAPI:
889
1542
  if release:
890
1543
  return {
891
1544
  "release": str(release.code) if release.code else "4.1",
892
- "publication_date": (
893
- release.date.strftime("%Y-%m-%d") if release.date else "2001-01-01"
894
- ),
1545
+ "publication_date": format_date(release.date),
895
1546
  }
896
1547
 
897
1548
  # Final fallback
@@ -958,49 +1609,133 @@ class ASTGeneratorAPI:
958
1609
  precondition: Optional[str],
959
1610
  context: Optional[Dict[str, Any]],
960
1611
  operation_code: str,
961
- engine,
1612
+ release_id: Optional[int] = None,
962
1613
  ) -> tuple:
963
- """Build preconditions and precondition_variables sections."""
1614
+ """Build preconditions and precondition_variables sections.
1615
+
1616
+ Handles both simple preconditions like {v_C_47.00} and compound
1617
+ preconditions like {v_C_01.00} and {v_C_05.01} and {v_C_47.00}.
1618
+
1619
+ For compound preconditions, generates a full AST with BinOp nodes
1620
+ for 'and' operators connecting PreconditionItem nodes.
1621
+
1622
+ Uses ExplorerQueryAPI to fetch actual variable_id and variable_vid
1623
+ from the database based on variable codes.
1624
+
1625
+ Args:
1626
+ precondition: Precondition string like "{v_C_01.00}" or
1627
+ "{v_C_01.00} and {v_C_05.01} and {v_C_47.00}"
1628
+ context: Optional context dict
1629
+ operation_code: Operation code to associate with this precondition
1630
+ release_id: Optional release ID for filtering variable versions
1631
+ """
964
1632
  import re
1633
+ from py_dpm.api.dpm.explorer import ExplorerQueryAPI
965
1634
 
966
1635
  preconditions = {}
967
1636
  precondition_variables = {}
968
1637
 
969
- # Extract table code from precondition or context
970
- table_code = None
1638
+ if not precondition:
1639
+ return preconditions, precondition_variables
971
1640
 
972
- if precondition:
973
- # Extract variable code from precondition reference like {v_F_44_04}
974
- match = re.match(r"\{v_([^}]+)\}", precondition)
975
- if match:
976
- table_code = match.group(1)
977
- elif context and "table" in context:
978
- table_code = context["table"]
979
-
980
- if table_code:
981
- # Query database for actual variable ID and version
982
- table_info = self._get_table_info(table_code, engine)
983
-
984
- if table_info:
985
- precondition_var_id = table_info["table_vid"]
986
- version_id = table_info["table_vid"]
987
- precondition_code = f"p_{precondition_var_id}"
988
-
989
- preconditions[precondition_code] = {
990
- "ast": {
991
- "class_name": "PreconditionItem",
992
- "variable_id": precondition_var_id,
993
- "variable_code": table_code,
994
- },
995
- "affected_operations": [operation_code],
996
- "version_id": version_id,
997
- "code": precondition_code,
1641
+ # Extract all variable codes from precondition (handles both simple and compound)
1642
+ # Pattern matches {v_VARIABLE_CODE} references
1643
+ var_matches = re.findall(r"\{v_([^}]+)\}", precondition)
1644
+
1645
+ if not var_matches:
1646
+ return preconditions, precondition_variables
1647
+
1648
+ # Normalize variable codes (F_44_04 -> F_44.04)
1649
+ variable_codes = [self._normalize_table_code(v) for v in var_matches]
1650
+
1651
+ # Batch lookup variable IDs from database (single query for efficiency)
1652
+ explorer_api = ExplorerQueryAPI()
1653
+ try:
1654
+ variables_info = explorer_api.get_variables_by_codes(
1655
+ variable_codes=variable_codes,
1656
+ release_id=release_id,
1657
+ )
1658
+ finally:
1659
+ explorer_api.close()
1660
+
1661
+ # Build variable infos list preserving order from precondition
1662
+ var_infos = []
1663
+ for var_code in variable_codes:
1664
+ if var_code in variables_info:
1665
+ info = variables_info[var_code]
1666
+ var_infos.append({
1667
+ "variable_code": var_code,
1668
+ "variable_id": info["variable_id"],
1669
+ "variable_vid": info["variable_vid"],
1670
+ })
1671
+ # Add to precondition_variables
1672
+ precondition_variables[str(info["variable_vid"])] = "b"
1673
+
1674
+ if not var_infos:
1675
+ return preconditions, precondition_variables
1676
+
1677
+ # Build the AST based on number of variables
1678
+ if len(var_infos) == 1:
1679
+ # Simple precondition - single PreconditionItem
1680
+ info = var_infos[0]
1681
+ precondition_code = f"p_{info['variable_vid']}"
1682
+
1683
+ preconditions[precondition_code] = {
1684
+ "ast": {
1685
+ "class_name": "PreconditionItem",
1686
+ "variable_id": info["variable_id"],
1687
+ "variable_code": info["variable_code"],
1688
+ },
1689
+ "affected_operations": [operation_code],
1690
+ "version_id": info["variable_vid"],
1691
+ "code": precondition_code,
1692
+ }
1693
+ else:
1694
+ # Compound precondition - build BinOp tree with 'and' operators
1695
+ # Create a unique key based on sorted variable VIDs
1696
+ sorted_var_vids = sorted([info["variable_vid"] for info in var_infos])
1697
+ precondition_code = "p_" + "_".join(str(vid) for vid in sorted_var_vids)
1698
+
1699
+ # Build AST: left-associative chain of BinOp 'and' nodes
1700
+ # E.g., for [A, B, C]: ((A and B) and C)
1701
+ ast = self._build_precondition_item_ast(var_infos[0])
1702
+ for info in var_infos[1:]:
1703
+ right_ast = self._build_precondition_item_ast(info)
1704
+ ast = {
1705
+ "class_name": "BinOp",
1706
+ "op": "and",
1707
+ "left": ast,
1708
+ "right": right_ast,
998
1709
  }
999
1710
 
1000
- precondition_variables[str(precondition_var_id)] = "b"
1711
+ # Use the first variable's VID as version_id
1712
+ preconditions[precondition_code] = {
1713
+ "ast": ast,
1714
+ "affected_operations": [operation_code],
1715
+ "version_id": sorted_var_vids[0],
1716
+ "code": precondition_code,
1717
+ }
1001
1718
 
1002
1719
  return preconditions, precondition_variables
1003
1720
 
1721
+ def _build_precondition_item_ast(self, var_info: Dict[str, Any]) -> Dict[str, Any]:
1722
+ """Build a PreconditionItem AST node for a single variable."""
1723
+ return {
1724
+ "class_name": "PreconditionItem",
1725
+ "variable_id": var_info["variable_id"],
1726
+ "variable_code": var_info["variable_code"],
1727
+ }
1728
+
1729
+ def _normalize_table_code(self, table_code: str) -> str:
1730
+ """Normalize table/variable code format (e.g., F_44_04 -> F_44.04)."""
1731
+ import re
1732
+ # Handle format like C_01_00 -> C_01.00 or F_44_04 -> F_44.04
1733
+ match = re.match(r"([A-Z]+)_(\d+)_(\d+)", table_code)
1734
+ if match:
1735
+ return f"{match.group(1)}_{match.group(2)}.{match.group(3)}"
1736
+ # Already in correct format or different format
1737
+ return table_code
1738
+
1004
1739
  def _extract_variables_from_ast(self, ast_dict: Dict[str, Any]) -> tuple:
1005
1740
  """
1006
1741
  Extract variables from complete AST by table.
@@ -1121,6 +1856,7 @@ class ASTGeneratorAPI:
1121
1856
  primary_module_vid: Optional[int],
1122
1857
  operation_code: str,
1123
1858
  release_id: Optional[int] = None,
1859
+ preferred_module_dependencies: Optional[List[str]] = None,
1124
1860
  ) -> tuple:
1125
1861
  """
1126
1862
  Detect cross-module dependencies for a single expression.
@@ -1134,6 +1870,8 @@ class ASTGeneratorAPI:
1134
1870
  primary_module_vid: The module being exported (if known)
1135
1871
  operation_code: Current operation code
1136
1872
  release_id: Optional release ID for filtering
1873
+ preferred_module_dependencies: Optional list of module codes to prefer when
1874
+ a table belongs to multiple modules
1137
1875
 
1138
1876
  Returns:
1139
1877
  Tuple of (dependency_modules, cross_instance_dependencies)
@@ -1187,30 +1925,54 @@ class ASTGeneratorAPI:
1187
1925
  return ref or "T"
1188
1926
 
1189
1927
  # Helper to lookup variables for a table
1190
- def get_table_variables(table_code: str) -> dict:
1928
+ # For external module tables, fetch from database if not in variables_by_table
1929
+ from py_dpm.api.dpm import DataDictionaryAPI
1930
+ data_dict_api = DataDictionaryAPI(
1931
+ database_path=self.database_path,
1932
+ connection_url=self.connection_url
1933
+ )
1934
+
1935
+ def get_table_variables(table_code: str, table_vid: int = None) -> dict:
1191
1936
  if not table_code:
1192
1937
  return {}
1938
+ # First try from passed variables_by_table
1193
1939
  variables = variables_by_table.get(table_code)
1194
1940
  if not variables:
1195
1941
  variables = variables_by_table.get(f"t{table_code}", {})
1942
+ # If still empty and table_vid is provided, fetch from database
1943
+ if not variables and table_vid:
1944
+ variables = data_dict_api.get_all_variables_for_table(table_vid)
1196
1945
  return variables or {}
1197
1946
 
1198
1947
  # Group external tables by module
1948
+ # If preferred_module_dependencies is set, only include those modules
1199
1949
  external_modules = {}
1950
+
1951
+ # TEMPORARY WORKAROUND: Also collect primary module tables to add to dependency_modules
1952
+ # This is conceptually wrong but required for current implementation.
1953
+ # See /docs/dependency_modules_main_tables_workaround.md for how to revert this.
1954
+ primary_module_tables = []
1955
+
1200
1956
  for table_info in tables_with_modules:
1201
1957
  module_vid = table_info.get("module_vid")
1202
1958
  if module_vid == primary_module_vid:
1203
- continue # Skip primary module
1959
+ # Collect primary module tables for later inclusion in dependency_modules
1960
+ primary_module_tables.append(table_info)
1961
+ continue # Skip for now, will add later
1204
1962
 
1205
- module_code = table_info.get("module_code")
1206
- if not module_code:
1963
+ ext_module_code = table_info.get("module_code")
1964
+ if not ext_module_code:
1965
+ continue
1966
+
1967
+ # If preferred_module_dependencies is set, only include preferred modules
1968
+ if preferred_module_dependencies and ext_module_code not in preferred_module_dependencies:
1207
1969
  continue
1208
1970
 
1209
1971
  # Get module URI
1210
1972
  try:
1211
1973
  module_uri = ExplorerQuery.get_module_url(
1212
1974
  scopes_api.session,
1213
- module_code=module_code,
1975
+ module_code=ext_module_code,
1214
1976
  release_id=release_id,
1215
1977
  )
1216
1978
  if module_uri.endswith(".json"):
@@ -1237,13 +1999,29 @@ class ASTGeneratorAPI:
1237
1999
 
1238
2000
  # Add table and variables
1239
2001
  if table_code:
1240
- table_variables = get_table_variables(table_code)
2002
+ table_vid = table_info.get("table_vid")
2003
+ table_variables = get_table_variables(table_code, table_vid)
1241
2004
  external_modules[module_uri]["tables"][table_code] = {
1242
2005
  "variables": table_variables,
1243
2006
  "open_keys": {}
1244
2007
  }
1245
2008
  external_modules[module_uri]["variables"].update(table_variables)
1246
2009
 
2010
+ # TEMPORARY WORKAROUND: Add primary module tables to each dependency module entry
2011
+ # This includes main module tables/variables in dependency_modules for cross-module validations
2012
+ # See /docs/dependency_modules_main_tables_workaround.md for how to revert this.
2013
+ for uri in external_modules:
2014
+ for table_info in primary_module_tables:
2015
+ table_code = table_info.get("code")
2016
+ if table_code:
2017
+ table_vid = table_info.get("table_vid")
2018
+ table_variables = get_table_variables(table_code, table_vid)
2019
+ external_modules[uri]["tables"][table_code] = {
2020
+ "variables": table_variables,
2021
+ "open_keys": {}
2022
+ }
2023
+ external_modules[uri]["variables"].update(table_variables)
2024
+
1247
2025
  # Get date info from scopes metadata
1248
2026
  scopes_metadata = scopes_api.get_scopes_with_metadata_from_expression(
1249
2027
  expression=expression,
@@ -1286,6 +2064,8 @@ class ASTGeneratorAPI:
1286
2064
  "to_reference_date": str(to_date) if to_date else ""
1287
2065
  })
1288
2066
 
2067
+ # Close data_dict_api before returning
2068
+ data_dict_api.close()
1289
2069
  return dependency_modules, cross_instance_dependencies
1290
2070
 
1291
2071
  except Exception as e:
@@ -1297,53 +2077,96 @@ class ASTGeneratorAPI:
1297
2077
  def _add_coordinates_to_ast(
1298
2078
  self, ast_dict: Dict[str, Any], context: Optional[Dict[str, Any]]
1299
2079
  ) -> Dict[str, Any]:
1300
- """Add x/y/z coordinates to data entries in AST."""
2080
+ """
2081
+ Add x/y/z coordinates to data entries in AST.
2082
+
2083
+ Coordinates are assigned based on:
2084
+ - x: row position (1-indexed)
2085
+ - y: column position (1-indexed)
2086
+ - z: sheet position (1-indexed)
2087
+
2088
+ If context provides column/row/sheet lists, those are used for ordering.
2089
+ Otherwise, the order is extracted from the data entries themselves.
2090
+ """
1301
2091
  import copy
1302
2092
 
1303
2093
  def add_coords_to_node(node):
1304
2094
  if isinstance(node, dict):
1305
2095
  # Handle VarID nodes with data arrays
1306
2096
  if node.get("class_name") == "VarID" and "data" in node:
1307
- # Get column information from context
1308
- cols = []
1309
- if context and "columns" in context and context["columns"]:
1310
- cols = context["columns"]
1311
-
1312
- # Group data entries by row to assign coordinates correctly
1313
- entries_by_row = {}
1314
- for data_entry in node["data"]:
1315
- row_code = data_entry.get("row", "")
1316
- if row_code not in entries_by_row:
1317
- entries_by_row[row_code] = []
1318
- entries_by_row[row_code].append(data_entry)
1319
-
1320
- # Assign coordinates based on column order and row grouping
1321
- rows = list(entries_by_row.keys())
1322
- for x_index, row_code in enumerate(rows, 1):
1323
- for data_entry in entries_by_row[row_code]:
1324
- column_code = data_entry.get("column", "")
1325
-
1326
- # Find y coordinate based on column position in context
1327
- y_index = 1 # default
1328
- if cols and column_code in cols:
1329
- y_index = cols.index(column_code) + 1
1330
- elif cols:
1331
- # Fallback to order in data
1332
- row_columns = [
1333
- entry.get("column", "")
1334
- for entry in entries_by_row[row_code]
1335
- ]
1336
- if column_code in row_columns:
1337
- y_index = row_columns.index(column_code) + 1
1338
-
1339
- # Always add y coordinate
1340
- data_entry["y"] = y_index
1341
-
1342
- # Add x coordinate only if there are multiple rows
2097
+ data_entries = node["data"]
2098
+ if not data_entries:
2099
+ return
2100
+
2101
+ # Get context lists (may be empty)
2102
+ context_cols = []
2103
+ context_rows = []
2104
+ context_sheets = []
2105
+ if context:
2106
+ context_cols = context.get("columns") or []
2107
+ context_rows = context.get("rows") or []
2108
+ context_sheets = context.get("sheets") or []
2109
+
2110
+ # Extract unique rows, columns, sheets from data entries
2111
+ # Use these if context doesn't provide them
2112
+ data_rows = []
2113
+ data_cols = []
2114
+ data_sheets = []
2115
+ seen_rows = set()
2116
+ seen_cols = set()
2117
+ seen_sheets = set()
2118
+
2119
+ for entry in data_entries:
2120
+ row = entry.get("row", "")
2121
+ col = entry.get("column", "")
2122
+ sheet = entry.get("sheet", "")
2123
+ if row and row not in seen_rows:
2124
+ data_rows.append(row)
2125
+ seen_rows.add(row)
2126
+ if col and col not in seen_cols:
2127
+ data_cols.append(col)
2128
+ seen_cols.add(col)
2129
+ if sheet and sheet not in seen_sheets:
2130
+ data_sheets.append(sheet)
2131
+ seen_sheets.add(sheet)
2132
+
2133
+ # Sort for consistent ordering
2134
+ data_rows.sort()
2135
+ data_cols.sort()
2136
+ data_sheets.sort()
2137
+
2138
+ # Use context lists if provided, otherwise use extracted lists
2139
+ rows = context_rows if context_rows else data_rows
2140
+ cols = context_cols if context_cols else data_cols
2141
+ sheets = context_sheets if context_sheets else data_sheets
2142
+
2143
+ # Assign coordinates to each data entry
2144
+ for entry in data_entries:
2145
+ row_code = entry.get("row", "")
2146
+ col_code = entry.get("column", "")
2147
+ sheet_code = entry.get("sheet", "")
2148
+
2149
+ # Calculate x coordinate (row position)
2150
+ if rows and row_code in rows:
2151
+ x_index = rows.index(row_code) + 1
2152
+ # Only add x if there are multiple rows
1343
2153
  if len(rows) > 1:
1344
- data_entry["x"] = x_index
1345
-
1346
- # TODO: Add z coordinate for sheets when needed
2154
+ entry["x"] = x_index
2155
+
2156
+ # Calculate y coordinate (column position)
2157
+ if cols and col_code in cols:
2158
+ y_index = cols.index(col_code) + 1
2159
+ entry["y"] = y_index
2160
+ elif not cols:
2161
+ # Default to 1 if no column info
2162
+ entry["y"] = 1
2163
+
2164
+ # Calculate z coordinate (sheet position)
2165
+ if sheets and sheet_code in sheets:
2166
+ z_index = sheets.index(sheet_code) + 1
2167
+ # Only add z if there are multiple sheets
2168
+ if len(sheets) > 1:
2169
+ entry["z"] = z_index
1347
2170
 
1348
2171
  # Recursively process child nodes
1349
2172
  for key, value in node.items():
@@ -1358,6 +2181,44 @@ class ASTGeneratorAPI:
1358
2181
  add_coords_to_node(result)
1359
2182
  return result
1360
2183
 
2184
+ def _clean_ast_data_entries(self, ast_dict: Dict[str, Any]) -> Dict[str, Any]:
2185
+ """
2186
+ Remove extra fields from data entries in the AST.
2187
+
2188
+ Keeps only the fields required by the engine:
2189
+ - datapoint, operand_reference_id, y, column, x (if multiple rows), z (if multiple sheets)
2190
+
2191
+ Removes internal/debug fields:
2192
+ - data_type, cell_code, table_code, table_vid, row
2193
+ """
2194
+ # Fields to keep in data entries
2195
+ ALLOWED_FIELDS = {"datapoint", "operand_reference_id", "x", "y", "z", "column", "sheet"}
2196
+
2197
+ def clean_node(node):
2198
+ if isinstance(node, dict):
2199
+ # Handle VarID nodes with data arrays
2200
+ if node.get("class_name") == "VarID" and "data" in node:
2201
+ cleaned_data = []
2202
+ for data_entry in node["data"]:
2203
+ # Keep only allowed fields
2204
+ cleaned_entry = {
2205
+ k: v for k, v in data_entry.items() if k in ALLOWED_FIELDS
2206
+ }
2207
+ cleaned_data.append(cleaned_entry)
2208
+ node["data"] = cleaned_data
2209
+
2210
+ # Recursively process child nodes
2211
+ for key, value in node.items():
2212
+ if isinstance(value, (dict, list)):
2213
+ clean_node(value)
2214
+ elif isinstance(node, list):
2215
+ for item in node:
2216
+ clean_node(item)
2217
+
2218
+ # Modify in place (ast_dict is already a copy from _add_coordinates_to_ast)
2219
+ clean_node(ast_dict)
2220
+ return ast_dict
2221
+
1361
2222
 
1362
2223
  # Convenience functions for simple usage
1363
2224
 
@@ -1389,18 +2250,3 @@ def validate_expression(expression: str) -> bool:
1389
2250
  generator = ASTGeneratorAPI()
1390
2251
  result = generator.validate_expression(expression)
1391
2252
  return result['valid']
1392
-
1393
-
1394
- def parse_batch(expressions: List[str], compatibility_mode: str = "auto") -> List[Dict[str, Any]]:
1395
- """
1396
- Simple function to parse multiple expressions.
1397
-
1398
- Args:
1399
- expressions: List of DPM-XL expression strings
1400
- compatibility_mode: Version compatibility mode
1401
-
1402
- Returns:
1403
- List of parse results
1404
- """
1405
- generator = ASTGeneratorAPI(compatibility_mode=compatibility_mode)
1406
- return generator.parse_batch(expressions)