mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-05 06:41:42 +08:00
177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Query SObject field definitions and match them to action inputs/outputs for smart code generation.
|
|
|
|
Usage:
|
|
python3 scripts/org_describe.py --sobject Account -o OrgAlias
|
|
python3 scripts/org_describe.py --sobject Case -o OrgAlias --json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import difflib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass
|
|
class FieldInfo:
|
|
"""SObject field metadata."""
|
|
name: str
|
|
label: str
|
|
data_type: str
|
|
filterable: bool
|
|
|
|
|
|
@dataclass
|
|
class FieldMapping:
|
|
"""Mapping between action inputs/outputs and SObject fields."""
|
|
input_mappings: dict[str, str] = field(default_factory=dict) # input_name → field_name
|
|
output_mappings: dict[str, str] = field(default_factory=dict) # output_name → field_name
|
|
select_fields: list[str] = field(default_factory=list)
|
|
where_fields: list[str] = field(default_factory=list)
|
|
|
|
|
|
def describe_sobject(object_name: str, target_org: str) -> list[FieldInfo]:
|
|
"""Query FieldDefinition for an SObject's fields."""
|
|
query = (
|
|
f"SELECT QualifiedApiName, Label, DataType, IsCompactLayoutable "
|
|
f"FROM FieldDefinition "
|
|
f"WHERE EntityDefinition.QualifiedApiName = '{object_name}'"
|
|
)
|
|
cmd = ["sf", "data", "query", "--query", query, "-o", target_org, "--json"]
|
|
try:
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
if proc.returncode != 0:
|
|
return []
|
|
data = json.loads(proc.stdout)
|
|
records = data.get("result", {}).get("records", [])
|
|
return [
|
|
FieldInfo(
|
|
name=r.get("QualifiedApiName", ""),
|
|
label=r.get("Label", ""),
|
|
data_type=r.get("DataType", ""),
|
|
filterable=r.get("IsCompactLayoutable", False),
|
|
)
|
|
for r in records
|
|
if r.get("QualifiedApiName")
|
|
]
|
|
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
return []
|
|
|
|
|
|
def match_fields(
|
|
inputs: list[dict],
|
|
outputs: list[dict],
|
|
fields: list[FieldInfo],
|
|
) -> FieldMapping:
|
|
"""Match action inputs/outputs to SObject fields.
|
|
|
|
Args:
|
|
inputs: List of dicts with 'name' and 'type' keys.
|
|
outputs: List of dicts with 'name' and 'type' keys.
|
|
fields: SObject field definitions from describe_sobject().
|
|
|
|
Returns:
|
|
FieldMapping with matched input/output mappings.
|
|
"""
|
|
mapping = FieldMapping()
|
|
|
|
# Build field lookup
|
|
field_names = [f.name for f in fields]
|
|
filterable_fields = [f.name for f in fields if f.filterable]
|
|
|
|
# Match inputs to filterable fields (for WHERE clause)
|
|
for inp in inputs:
|
|
if _is_computed_output(inp["name"]):
|
|
continue
|
|
best = _find_best_match(inp["name"], filterable_fields)
|
|
if best:
|
|
mapping.input_mappings[inp["name"]] = best
|
|
mapping.where_fields.append(best)
|
|
|
|
# Match outputs to any fields (for SELECT clause)
|
|
for out in outputs:
|
|
if _is_computed_output(out["name"]):
|
|
continue
|
|
best = _find_best_match(out["name"], field_names)
|
|
if best:
|
|
mapping.output_mappings[out["name"]] = best
|
|
mapping.select_fields.append(best)
|
|
|
|
return mapping
|
|
|
|
|
|
def _find_best_match(name: str, candidates: list[str], threshold: float = 0.5) -> str | None:
|
|
"""Find the best matching field name using fuzzy matching."""
|
|
normalized = _normalize(name)
|
|
|
|
# Exact normalized match
|
|
for cand in candidates:
|
|
if _normalize(cand) == normalized:
|
|
return cand
|
|
|
|
# Fuzzy sequence matching
|
|
best_score = 0.0
|
|
best_match = None
|
|
for cand in candidates:
|
|
score = difflib.SequenceMatcher(None, normalized, _normalize(cand)).ratio()
|
|
# Bonus for word containment
|
|
if normalized in _normalize(cand) or _normalize(cand) in normalized:
|
|
score += 0.2
|
|
if score > best_score and score >= threshold:
|
|
best_score = score
|
|
best_match = cand
|
|
|
|
return best_match
|
|
|
|
|
|
def _normalize(name: str) -> str:
|
|
"""Normalize a field name for comparison."""
|
|
# Remove __c suffix
|
|
name = re.sub(r"__c$", "", name)
|
|
# Split camelCase
|
|
name = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name)
|
|
# Replace underscores with spaces
|
|
name = name.replace("_", " ")
|
|
return name.lower().strip()
|
|
|
|
|
|
def _is_computed_output(name: str) -> bool:
|
|
"""Detect computed output names that shouldn't be mapped to fields."""
|
|
computed_patterns = [
|
|
"total_count", "result_json", "error_message", "status_code",
|
|
"success", "is_valid", "record_count",
|
|
]
|
|
return name.lower() in computed_patterns or name.endswith("_count") or name.endswith("_json")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Describe SObject fields")
|
|
parser.add_argument("--sobject", required=True, help="SObject API name (e.g. Account, Case)")
|
|
parser.add_argument("-o", "--target-org", required=True, help="Salesforce org alias")
|
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
args = parser.parse_args()
|
|
|
|
fields = describe_sobject(args.sobject, args.target_org)
|
|
if not fields:
|
|
print(f"No fields found for {args.sobject}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.json:
|
|
print(json.dumps([{"name": f.name, "label": f.label, "type": f.data_type, "filterable": f.filterable} for f in fields], indent=2))
|
|
else:
|
|
print(f"\n{args.sobject} — {len(fields)} fields\n")
|
|
print(f"{'Field':<40} {'Label':<30} {'Type':<15} {'Filter'}")
|
|
print(f"{'-'*40} {'-'*30} {'-'*15} {'-'*6}")
|
|
for f in fields:
|
|
filt = "✓" if f.filterable else ""
|
|
print(f"{f.name:<40} {f.label:<30} {f.data_type:<15} {filt}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|