amplify-excel-migrator 1.1.5__py3-none-any.whl → 1.2.15__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.
- amplify_excel_migrator/__init__.py +17 -0
- amplify_excel_migrator/auth/__init__.py +6 -0
- amplify_excel_migrator/auth/cognito_auth.py +306 -0
- amplify_excel_migrator/auth/provider.py +42 -0
- amplify_excel_migrator/cli/__init__.py +5 -0
- amplify_excel_migrator/cli/commands.py +165 -0
- amplify_excel_migrator/client.py +47 -0
- amplify_excel_migrator/core/__init__.py +5 -0
- amplify_excel_migrator/core/config.py +98 -0
- amplify_excel_migrator/data/__init__.py +7 -0
- amplify_excel_migrator/data/excel_reader.py +23 -0
- amplify_excel_migrator/data/transformer.py +119 -0
- amplify_excel_migrator/data/validator.py +48 -0
- amplify_excel_migrator/graphql/__init__.py +8 -0
- amplify_excel_migrator/graphql/client.py +137 -0
- amplify_excel_migrator/graphql/executor.py +405 -0
- amplify_excel_migrator/graphql/mutation_builder.py +80 -0
- amplify_excel_migrator/graphql/query_builder.py +194 -0
- amplify_excel_migrator/migration/__init__.py +8 -0
- amplify_excel_migrator/migration/batch_uploader.py +23 -0
- amplify_excel_migrator/migration/failure_tracker.py +92 -0
- amplify_excel_migrator/migration/orchestrator.py +143 -0
- amplify_excel_migrator/migration/progress_reporter.py +57 -0
- amplify_excel_migrator/schema/__init__.py +6 -0
- model_field_parser.py → amplify_excel_migrator/schema/field_parser.py +100 -22
- amplify_excel_migrator/schema/introspector.py +95 -0
- {amplify_excel_migrator-1.1.5.dist-info → amplify_excel_migrator-1.2.15.dist-info}/METADATA +121 -26
- amplify_excel_migrator-1.2.15.dist-info/RECORD +40 -0
- amplify_excel_migrator-1.2.15.dist-info/entry_points.txt +2 -0
- amplify_excel_migrator-1.2.15.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_cli_commands.py +292 -0
- tests/test_client.py +187 -0
- tests/test_cognito_auth.py +363 -0
- tests/test_config_manager.py +347 -0
- tests/test_field_parser.py +615 -0
- tests/test_mutation_builder.py +391 -0
- tests/test_query_builder.py +384 -0
- amplify_client.py +0 -941
- amplify_excel_migrator-1.1.5.dist-info/RECORD +0 -9
- amplify_excel_migrator-1.1.5.dist-info/entry_points.txt +0 -2
- amplify_excel_migrator-1.1.5.dist-info/top_level.txt +0 -3
- migrator.py +0 -437
- {amplify_excel_migrator-1.1.5.dist-info → amplify_excel_migrator-1.2.15.dist-info}/WHEEL +0 -0
- {amplify_excel_migrator-1.1.5.dist-info → amplify_excel_migrator-1.2.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Handles batch uploading of records to Amplify."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, List, Tuple, Any
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BatchUploader:
|
|
10
|
+
def __init__(self, amplify_client):
|
|
11
|
+
self.amplify_client = amplify_client
|
|
12
|
+
|
|
13
|
+
def upload_records(
|
|
14
|
+
self, records: List[Dict], sheet_name: str, parsed_model_structure: Dict[str, Any]
|
|
15
|
+
) -> Tuple[int, int, List[Dict]]:
|
|
16
|
+
if not records:
|
|
17
|
+
return 0, 0, []
|
|
18
|
+
|
|
19
|
+
success_count, upload_error_count, failed_uploads = self.amplify_client.upload(
|
|
20
|
+
records, sheet_name, parsed_model_structure
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return success_count, upload_error_count, failed_uploads
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Tracks and manages failed records during migration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FailureTracker:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._failures_by_sheet: Dict[str, List[Dict]] = {}
|
|
16
|
+
self._current_sheet: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
def set_current_sheet(self, sheet_name: str) -> None:
|
|
19
|
+
self._current_sheet = sheet_name
|
|
20
|
+
if sheet_name not in self._failures_by_sheet:
|
|
21
|
+
self._failures_by_sheet[sheet_name] = []
|
|
22
|
+
|
|
23
|
+
def record_failure(
|
|
24
|
+
self,
|
|
25
|
+
primary_field: str,
|
|
26
|
+
primary_field_value: str,
|
|
27
|
+
error: str,
|
|
28
|
+
original_row: Optional[Dict] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if self._current_sheet is None:
|
|
31
|
+
raise RuntimeError("No current sheet set. Call set_current_sheet() first.")
|
|
32
|
+
|
|
33
|
+
failure_record = {
|
|
34
|
+
"primary_field": primary_field,
|
|
35
|
+
"primary_field_value": primary_field_value,
|
|
36
|
+
"error": error,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if original_row is not None:
|
|
40
|
+
failure_record["original_row"] = original_row
|
|
41
|
+
|
|
42
|
+
self._failures_by_sheet[self._current_sheet].append(failure_record)
|
|
43
|
+
|
|
44
|
+
def get_failures(self, sheet_name: Optional[str] = None) -> List[Dict]:
|
|
45
|
+
if sheet_name:
|
|
46
|
+
return self._failures_by_sheet.get(sheet_name, [])
|
|
47
|
+
return [failure for failures in self._failures_by_sheet.values() for failure in failures]
|
|
48
|
+
|
|
49
|
+
def get_failures_by_sheet(self) -> Dict[str, List[Dict]]:
|
|
50
|
+
return self._failures_by_sheet.copy()
|
|
51
|
+
|
|
52
|
+
def get_total_failure_count(self) -> int:
|
|
53
|
+
return sum(len(failures) for failures in self._failures_by_sheet.values())
|
|
54
|
+
|
|
55
|
+
def has_failures(self) -> bool:
|
|
56
|
+
return any(len(failures) > 0 for failures in self._failures_by_sheet.values())
|
|
57
|
+
|
|
58
|
+
def export_to_excel(self, original_excel_path: str) -> Optional[str]:
|
|
59
|
+
if not self.has_failures():
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
input_path = Path(original_excel_path)
|
|
63
|
+
base_name = input_path.stem
|
|
64
|
+
if "_failed_records_" in base_name:
|
|
65
|
+
base_name = base_name.split("_failed_records_")[0]
|
|
66
|
+
|
|
67
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
68
|
+
output_filename = f"{base_name}_failed_records_{timestamp}.xlsx"
|
|
69
|
+
output_path = input_path.parent / output_filename
|
|
70
|
+
|
|
71
|
+
logger.info(f"Writing failed records to {output_path}")
|
|
72
|
+
|
|
73
|
+
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
|
|
74
|
+
for sheet_name, failed_records in self._failures_by_sheet.items():
|
|
75
|
+
if not failed_records:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
rows_data = []
|
|
79
|
+
for record in failed_records:
|
|
80
|
+
row_data = record.get("original_row", {}).copy()
|
|
81
|
+
row_data["ERROR"] = record["error"]
|
|
82
|
+
rows_data.append(row_data)
|
|
83
|
+
|
|
84
|
+
df = pd.DataFrame(rows_data)
|
|
85
|
+
df.to_excel(writer, sheet_name=sheet_name, index=False)
|
|
86
|
+
|
|
87
|
+
logger.info(f"Successfully wrote failed records to {output_path}")
|
|
88
|
+
return str(output_path)
|
|
89
|
+
|
|
90
|
+
def clear(self) -> None:
|
|
91
|
+
self._failures_by_sheet.clear()
|
|
92
|
+
self._current_sheet = None
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Main migration orchestrator that coordinates the entire migration process."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from amplify_excel_migrator.client import AmplifyClient
|
|
10
|
+
from amplify_excel_migrator.data import ExcelReader, DataTransformer
|
|
11
|
+
from amplify_excel_migrator.schema import FieldParser
|
|
12
|
+
from amplify_excel_migrator.migration import FailureTracker, ProgressReporter, BatchUploader
|
|
13
|
+
from amplify_excel_migrator.core import ConfigManager
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MigrationOrchestrator:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
excel_reader: ExcelReader,
|
|
22
|
+
data_transformer: DataTransformer,
|
|
23
|
+
amplify_client: AmplifyClient,
|
|
24
|
+
failure_tracker: FailureTracker,
|
|
25
|
+
progress_reporter: ProgressReporter,
|
|
26
|
+
batch_uploader: BatchUploader,
|
|
27
|
+
field_parser: FieldParser,
|
|
28
|
+
):
|
|
29
|
+
self.excel_reader = excel_reader
|
|
30
|
+
self.data_transformer = data_transformer
|
|
31
|
+
self.amplify_client = amplify_client
|
|
32
|
+
self.failure_tracker = failure_tracker
|
|
33
|
+
self.progress_reporter = progress_reporter
|
|
34
|
+
self.batch_uploader = batch_uploader
|
|
35
|
+
self.field_parser = field_parser
|
|
36
|
+
|
|
37
|
+
def run(self) -> int:
|
|
38
|
+
all_sheets = self.excel_reader.read_all_sheets()
|
|
39
|
+
|
|
40
|
+
total_success = 0
|
|
41
|
+
|
|
42
|
+
for sheet_name, df in all_sheets.items():
|
|
43
|
+
logger.info(f"Processing {sheet_name} sheet with {len(df)} rows")
|
|
44
|
+
total_success += self.process_sheet(df, sheet_name)
|
|
45
|
+
|
|
46
|
+
self._display_summary(len(all_sheets), total_success)
|
|
47
|
+
|
|
48
|
+
return total_success
|
|
49
|
+
|
|
50
|
+
def process_sheet(self, df: pd.DataFrame, sheet_name: str) -> int:
|
|
51
|
+
self.failure_tracker.set_current_sheet(sheet_name)
|
|
52
|
+
|
|
53
|
+
parsed_model_structure = self._get_parsed_model_structure(sheet_name)
|
|
54
|
+
|
|
55
|
+
records, row_dict_by_primary = self._transform_rows_to_records(df, parsed_model_structure, sheet_name)
|
|
56
|
+
|
|
57
|
+
confirm = input(f"\nUpload {len(records)} records of {sheet_name} to Amplify? (yes/no): ")
|
|
58
|
+
if confirm.lower() != "yes":
|
|
59
|
+
logger.info(f"Upload cancelled for {sheet_name} sheet")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
success_count, upload_error_count, failed_uploads = self.batch_uploader.upload_records(
|
|
63
|
+
records, sheet_name, parsed_model_structure
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for failed_upload in failed_uploads:
|
|
67
|
+
primary_value = str(failed_upload["primary_field_value"])
|
|
68
|
+
original_row = row_dict_by_primary.get(primary_value, {})
|
|
69
|
+
|
|
70
|
+
self.failure_tracker.record_failure(
|
|
71
|
+
primary_field=failed_upload["primary_field"],
|
|
72
|
+
primary_field_value=failed_upload["primary_field_value"],
|
|
73
|
+
error=failed_upload["error"],
|
|
74
|
+
original_row=original_row,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
failures = self.failure_tracker.get_failures(sheet_name)
|
|
78
|
+
parsing_failures = len(failures) - upload_error_count
|
|
79
|
+
|
|
80
|
+
self.progress_reporter.print_sheet_result(
|
|
81
|
+
sheet_name=sheet_name,
|
|
82
|
+
success_count=success_count,
|
|
83
|
+
total_rows=len(df),
|
|
84
|
+
parsing_failures=parsing_failures,
|
|
85
|
+
upload_failures=upload_error_count,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return success_count
|
|
89
|
+
|
|
90
|
+
def _transform_rows_to_records(
|
|
91
|
+
self,
|
|
92
|
+
df: pd.DataFrame,
|
|
93
|
+
parsed_model_structure: Dict[str, Any],
|
|
94
|
+
sheet_name: str,
|
|
95
|
+
) -> tuple[list[Any], Dict[str, Dict]]:
|
|
96
|
+
df.columns = [self.data_transformer.to_camel_case(c) for c in df.columns]
|
|
97
|
+
primary_field, _, _ = self.amplify_client.get_primary_field_name(sheet_name, parsed_model_structure)
|
|
98
|
+
|
|
99
|
+
fk_lookup_cache = {}
|
|
100
|
+
if self.amplify_client:
|
|
101
|
+
logger.info("🚀 Pre-fetching foreign key lookups...")
|
|
102
|
+
fk_lookup_cache = self.amplify_client.build_foreign_key_lookups(df, parsed_model_structure)
|
|
103
|
+
|
|
104
|
+
records, row_dict_by_primary, failed_rows = self.data_transformer.transform_rows_to_records(
|
|
105
|
+
df, parsed_model_structure, primary_field, fk_lookup_cache
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for failed_row in failed_rows:
|
|
109
|
+
self.failure_tracker.record_failure(
|
|
110
|
+
primary_field=failed_row["primary_field"],
|
|
111
|
+
primary_field_value=failed_row["primary_field_value"],
|
|
112
|
+
error=failed_row["error"],
|
|
113
|
+
original_row=failed_row["original_row"],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return records, row_dict_by_primary
|
|
117
|
+
|
|
118
|
+
def _get_parsed_model_structure(self, sheet_name: str) -> Dict[str, Any]:
|
|
119
|
+
model_structure = self.amplify_client.get_model_structure(sheet_name)
|
|
120
|
+
return self.field_parser.parse_model_structure(model_structure)
|
|
121
|
+
|
|
122
|
+
def _display_summary(self, sheets_processed: int, total_success: int) -> None:
|
|
123
|
+
failures_by_sheet = self.failure_tracker.get_failures_by_sheet()
|
|
124
|
+
|
|
125
|
+
self.progress_reporter.print_migration_summary(sheets_processed, total_success, failures_by_sheet)
|
|
126
|
+
|
|
127
|
+
if self.failure_tracker.has_failures():
|
|
128
|
+
export_confirm = input("\nExport failed records to Excel? (yes/no): ")
|
|
129
|
+
if export_confirm.lower() == "yes":
|
|
130
|
+
failed_records_file = self.failure_tracker.export_to_excel(self.excel_reader.file_path)
|
|
131
|
+
if failed_records_file:
|
|
132
|
+
print(f"📁 Failed records exported to: {failed_records_file}")
|
|
133
|
+
print("=" * 60)
|
|
134
|
+
|
|
135
|
+
update_config = input("\nUpdate config to use this failed records file for next run? (yes/no): ")
|
|
136
|
+
if update_config.lower() == "yes":
|
|
137
|
+
config_manager = ConfigManager()
|
|
138
|
+
config_manager.update({"excel_path": failed_records_file})
|
|
139
|
+
print(f"✅ Config updated! Next 'migrate' will use: {Path(failed_records_file).name}")
|
|
140
|
+
print("=" * 60)
|
|
141
|
+
else:
|
|
142
|
+
print("Failed records export skipped.")
|
|
143
|
+
print("=" * 60)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Handles progress and summary reporting during migration."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProgressReporter:
|
|
7
|
+
@staticmethod
|
|
8
|
+
def print_sheet_result(
|
|
9
|
+
sheet_name: str, success_count: int, total_rows: int, parsing_failures: int, upload_failures: int
|
|
10
|
+
) -> None:
|
|
11
|
+
print(f"=== Upload of Excel sheet: {sheet_name} Complete ===")
|
|
12
|
+
print(f"✅ Success: {success_count}")
|
|
13
|
+
total_failures = parsing_failures + upload_failures
|
|
14
|
+
print(f"❌ Failed: {total_failures} (Parsing: {parsing_failures}, Upload: {upload_failures})")
|
|
15
|
+
print(f"📊 Total: {total_rows}")
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def print_migration_summary(
|
|
19
|
+
sheets_processed: int, total_success: int, failures_by_sheet: Dict[str, List[Dict]]
|
|
20
|
+
) -> None:
|
|
21
|
+
total_failed = sum(len(failures) for failures in failures_by_sheet.values())
|
|
22
|
+
|
|
23
|
+
print("\n" + "=" * 60)
|
|
24
|
+
print("MIGRATION SUMMARY")
|
|
25
|
+
print("=" * 60)
|
|
26
|
+
print(f"📊 Sheets processed: {sheets_processed}")
|
|
27
|
+
print(f"✅ Total successful: {total_success}")
|
|
28
|
+
print(f"❌ Total failed: {total_failed}")
|
|
29
|
+
|
|
30
|
+
if (total_success + total_failed) > 0:
|
|
31
|
+
success_rate = (total_success / (total_success + total_failed)) * 100
|
|
32
|
+
print(f"📈 Success rate: {success_rate:.1f}%")
|
|
33
|
+
else:
|
|
34
|
+
print("📈 Success rate: N/A")
|
|
35
|
+
|
|
36
|
+
if total_failed > 0:
|
|
37
|
+
print("\n" + "=" * 60)
|
|
38
|
+
print("FAILED RECORDS DETAILS")
|
|
39
|
+
print("=" * 60)
|
|
40
|
+
|
|
41
|
+
for sheet_name, failed_records in failures_by_sheet.items():
|
|
42
|
+
if not failed_records:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
print(f"\n📄 {sheet_name}:")
|
|
46
|
+
print("-" * 60)
|
|
47
|
+
for record in failed_records:
|
|
48
|
+
primary_field_value = record.get("primary_field_value", "Unknown")
|
|
49
|
+
error = record.get("error", "Unknown error")
|
|
50
|
+
print(f" • Record: {primary_field_value}")
|
|
51
|
+
print(f" Error: {error}")
|
|
52
|
+
|
|
53
|
+
print("\n" + "=" * 60)
|
|
54
|
+
else:
|
|
55
|
+
print("\n✨ No failed records!")
|
|
56
|
+
|
|
57
|
+
print("=" * 60)
|
|
@@ -8,7 +8,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class
|
|
11
|
+
class FieldParser:
|
|
12
12
|
"""Parse GraphQL model fields from introspection results"""
|
|
13
13
|
|
|
14
14
|
def __init__(self):
|
|
@@ -220,29 +220,47 @@ class ModelFieldParser:
|
|
|
220
220
|
|
|
221
221
|
return custom_type_objects
|
|
222
222
|
|
|
223
|
-
def
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
def _convert_single_value(
|
|
224
|
+
self,
|
|
225
|
+
field: Dict[str, Any],
|
|
226
|
+
field_name: str,
|
|
227
|
+
input_value: Any,
|
|
228
|
+
use_dash_notation: bool = False,
|
|
229
|
+
index: int = None,
|
|
230
|
+
) -> Any:
|
|
231
|
+
if field["type"] in ["Int", "Integer"]:
|
|
232
|
+
if use_dash_notation:
|
|
226
233
|
parsed_value = self.parse_number_dash_notation(input_value)
|
|
227
|
-
return int(parsed_value)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
elif
|
|
241
|
-
return
|
|
242
|
-
elif field["type"] == "AWSDate" or field["type"] == "AWSDateTime":
|
|
243
|
-
return self.parse_date(input_value)
|
|
234
|
+
return int(parsed_value)
|
|
235
|
+
return int(input_value)
|
|
236
|
+
elif field["type"] == "Float":
|
|
237
|
+
if use_dash_notation:
|
|
238
|
+
parsed_value = self.parse_number_dash_notation(input_value)
|
|
239
|
+
return float(parsed_value)
|
|
240
|
+
return float(input_value)
|
|
241
|
+
elif field["type"] == "Boolean":
|
|
242
|
+
if isinstance(input_value, bool):
|
|
243
|
+
return input_value
|
|
244
|
+
input_str = str(input_value).strip().lower()
|
|
245
|
+
if input_str in ["true", "1", "v", "y", "yes"]:
|
|
246
|
+
return True
|
|
247
|
+
elif input_str in ["false", "0", "n", "x", "no"]:
|
|
248
|
+
return False
|
|
244
249
|
else:
|
|
245
|
-
|
|
250
|
+
context = f"array '{field_name}[{index}]'" if index is not None else f"field '{field_name}'"
|
|
251
|
+
logger.error(f"Invalid Boolean value for {context}: {input_value}")
|
|
252
|
+
return None
|
|
253
|
+
elif field.get("is_enum", False):
|
|
254
|
+
return str(input_value).strip().replace(" ", "_").upper()
|
|
255
|
+
elif field["type"] in ["AWSDate", "AWSDateTime"]:
|
|
256
|
+
return self.parse_date(input_value)
|
|
257
|
+
else:
|
|
258
|
+
return str(input_value).strip()
|
|
259
|
+
|
|
260
|
+
def parse_field_input(self, field: Dict[str, Any], field_name: str, input_value: Any) -> Any:
|
|
261
|
+
"""Parse a single field value from Excel"""
|
|
262
|
+
try:
|
|
263
|
+
return self._convert_single_value(field, field_name, input_value, use_dash_notation=True)
|
|
246
264
|
except (ValueError, TypeError) as e:
|
|
247
265
|
logger.warning(
|
|
248
266
|
f"Failed to parse field '{field_name}' with value '{input_value}' (type: {type(input_value).__name__}) "
|
|
@@ -251,6 +269,66 @@ class ModelFieldParser:
|
|
|
251
269
|
)
|
|
252
270
|
return None
|
|
253
271
|
|
|
272
|
+
def parse_scalar_array(self, field: Dict[str, Any], field_name: str, input_value: Any) -> list | None:
|
|
273
|
+
"""
|
|
274
|
+
Parse scalar array from Excel cell supporting multiple formats:
|
|
275
|
+
- JSON: ["value1", "value2", "value3"]
|
|
276
|
+
- Semicolon: value1; value2; value3
|
|
277
|
+
- Comma: value1, value2, value3
|
|
278
|
+
- Space: value1 value2 value3
|
|
279
|
+
"""
|
|
280
|
+
if pd.isna(input_value):
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
input_str = str(input_value).strip()
|
|
284
|
+
if not input_str:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
if input_str.startswith("[") and input_str.endswith("]"):
|
|
288
|
+
try:
|
|
289
|
+
import json
|
|
290
|
+
|
|
291
|
+
parsed_json = json.loads(input_str)
|
|
292
|
+
if isinstance(parsed_json, list):
|
|
293
|
+
return self._convert_array_elements(field, field_name, parsed_json)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
logger.warning(f"Failed to parse JSON array for field '{field_name}': {input_str}")
|
|
296
|
+
|
|
297
|
+
if ";" in input_str:
|
|
298
|
+
values = [v.strip() for v in input_str.split(";") if v.strip()]
|
|
299
|
+
return self._convert_array_elements(field, field_name, values)
|
|
300
|
+
|
|
301
|
+
if "," in input_str:
|
|
302
|
+
values = [v.strip() for v in input_str.split(",") if v.strip()]
|
|
303
|
+
return self._convert_array_elements(field, field_name, values)
|
|
304
|
+
|
|
305
|
+
values = [v.strip() for v in input_str.split() if v.strip()]
|
|
306
|
+
if len(values) > 1:
|
|
307
|
+
return self._convert_array_elements(field, field_name, values)
|
|
308
|
+
|
|
309
|
+
return self._convert_array_elements(field, field_name, [input_str])
|
|
310
|
+
|
|
311
|
+
def _convert_array_elements(self, field: Dict[str, Any], field_name: str, values: list) -> list:
|
|
312
|
+
converted = []
|
|
313
|
+
for i, value in enumerate(values):
|
|
314
|
+
cleaned_value = self.clean_input(value)
|
|
315
|
+
|
|
316
|
+
if not cleaned_value or (isinstance(cleaned_value, str) and not cleaned_value.strip()):
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
result = self._convert_single_value(field, field_name, cleaned_value, use_dash_notation=False, index=i)
|
|
321
|
+
if result is not None:
|
|
322
|
+
converted.append(result)
|
|
323
|
+
except (ValueError, TypeError) as e:
|
|
324
|
+
logger.warning(
|
|
325
|
+
f"Failed to convert array element '{field_name}[{i}]' with value '{cleaned_value}' "
|
|
326
|
+
f"to type '{field['type']}': {e}"
|
|
327
|
+
)
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
return converted if converted else None
|
|
331
|
+
|
|
254
332
|
@staticmethod
|
|
255
333
|
def parse_number_dash_notation(input_value: Any) -> int | float:
|
|
256
334
|
"""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Schema introspection for GraphQL models."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
import inflect
|
|
7
|
+
|
|
8
|
+
from amplify_excel_migrator.graphql import GraphQLClient
|
|
9
|
+
from amplify_excel_migrator.graphql.query_builder import QueryBuilder
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SchemaIntrospector:
|
|
15
|
+
def __init__(self, client: GraphQLClient):
|
|
16
|
+
self.client = client
|
|
17
|
+
|
|
18
|
+
def get_model_structure(self, model_type: str) -> Dict[str, Any]:
|
|
19
|
+
query = QueryBuilder.build_introspection_query(model_type)
|
|
20
|
+
response = self.client.request(query)
|
|
21
|
+
|
|
22
|
+
if response and "data" in response and "__type" in response["data"]:
|
|
23
|
+
return response["data"]["__type"]
|
|
24
|
+
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
def get_primary_field_name(self, model_name: str, parsed_model_structure: Dict[str, Any]) -> tuple[str, bool, str]:
|
|
28
|
+
secondary_index = self._get_secondary_index(model_name)
|
|
29
|
+
if secondary_index:
|
|
30
|
+
field_type = "String"
|
|
31
|
+
for field in parsed_model_structure["fields"]:
|
|
32
|
+
if field["name"] == secondary_index:
|
|
33
|
+
field_type = field["type"]
|
|
34
|
+
break
|
|
35
|
+
return secondary_index, True, field_type
|
|
36
|
+
|
|
37
|
+
for field in parsed_model_structure["fields"]:
|
|
38
|
+
if field["is_required"] and field["is_scalar"] and field["name"] != "id":
|
|
39
|
+
return field["name"], False, field["type"]
|
|
40
|
+
|
|
41
|
+
logger.error("No suitable primary field found (required scalar field other than id)")
|
|
42
|
+
return "", False, "String"
|
|
43
|
+
|
|
44
|
+
def _get_secondary_index(self, model_name: str) -> str:
|
|
45
|
+
query_structure = self.get_model_structure("Query")
|
|
46
|
+
if not query_structure:
|
|
47
|
+
logger.error("Query type not found in schema")
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
query_fields = query_structure["fields"]
|
|
51
|
+
pattern = f"{model_name}By"
|
|
52
|
+
|
|
53
|
+
for query in query_fields:
|
|
54
|
+
query_name = query["name"]
|
|
55
|
+
if pattern in query_name:
|
|
56
|
+
pattern_index = query_name.index(pattern)
|
|
57
|
+
field_name = query_name[pattern_index + len(pattern) :]
|
|
58
|
+
return field_name[0].lower() + field_name[1:] if field_name else ""
|
|
59
|
+
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
def get_list_query_name(self, model_name: str) -> Optional[str]:
|
|
63
|
+
query_structure = self.get_model_structure("Query")
|
|
64
|
+
if not query_structure:
|
|
65
|
+
logger.error("Query type not found in schema")
|
|
66
|
+
return f"list{model_name}s"
|
|
67
|
+
|
|
68
|
+
query_fields = query_structure["fields"]
|
|
69
|
+
p = inflect.engine()
|
|
70
|
+
|
|
71
|
+
candidates = [f"list{model_name}"]
|
|
72
|
+
capitals = [i for i, c in enumerate(model_name) if c.isupper()]
|
|
73
|
+
|
|
74
|
+
if len(capitals) > 1:
|
|
75
|
+
last_word_start = capitals[-1]
|
|
76
|
+
prefix = model_name[:last_word_start]
|
|
77
|
+
last_word = model_name[last_word_start:]
|
|
78
|
+
|
|
79
|
+
last_word_plural = str(p.plural(last_word.lower()))
|
|
80
|
+
last_word_plural_cap = last_word_plural[0].upper() + last_word_plural[1:] if last_word_plural else ""
|
|
81
|
+
|
|
82
|
+
pascal_plural = f"{prefix}{last_word_plural_cap}"
|
|
83
|
+
candidates.append(f"list{pascal_plural}")
|
|
84
|
+
|
|
85
|
+
full_plural = str(p.plural(model_name.lower()))
|
|
86
|
+
full_plural_cap = full_plural[0].upper() + full_plural[1:] if full_plural else ""
|
|
87
|
+
candidates.append(f"list{full_plural_cap}")
|
|
88
|
+
|
|
89
|
+
for query in query_fields:
|
|
90
|
+
query_name = query["name"]
|
|
91
|
+
if query_name in candidates and "By" not in query_name:
|
|
92
|
+
return query_name
|
|
93
|
+
|
|
94
|
+
logger.error(f"No list query found for model {model_name}, tried: {candidates}")
|
|
95
|
+
return None
|