@ -0,0 +1,406 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
""" |
||||
|
Comprehensive test suite for update_dependency_changes.py |
||||
|
|
||||
|
Tests cover: |
||||
|
- Basic update/add/remove scenarios |
||||
|
- Version revert scenarios |
||||
|
- Complex multi-step change sequences |
||||
|
- Edge cases and duplicate operations |
||||
|
- Document format validation |
||||
|
""" |
||||
|
|
||||
|
import sys |
||||
|
import os |
||||
|
sys.path.insert(0, os.path.dirname(__file__)) |
||||
|
|
||||
|
from update_dependency_changes import merge_changes, render_section |
||||
|
|
||||
|
|
||||
|
def test_update_then_revert(): |
||||
|
"""Test: PR1 updates A->B, PR2 reverts B->A. Should be removed.""" |
||||
|
print("Test 1: Update then revert") |
||||
|
existing = ( |
||||
|
{"PackageA": ("1.0.0", "2.0.0", "#1")}, # updated |
||||
|
{}, # added |
||||
|
{} # removed |
||||
|
) |
||||
|
new = ( |
||||
|
{"PackageA": ("2.0.0", "1.0.0", "#2")}, # updated back |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageA" not in updated, f"Expected PackageA removed, got: {updated}" |
||||
|
assert len(added) == 0 and len(removed) == 0 |
||||
|
print("✓ Passed: Package correctly removed from updates\n") |
||||
|
|
||||
|
|
||||
|
def test_add_then_remove_same_version(): |
||||
|
"""Test: PR1 adds v1.0, PR2 removes v1.0. Should be completely removed.""" |
||||
|
print("Test 2: Add then remove same version") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageB": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageB": ("1.0.0", "#2")} # removed |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageB" not in added, f"Expected PackageB removed from added, got: {added}" |
||||
|
assert "PackageB" not in removed, f"Expected PackageB removed from removed, got: {removed}" |
||||
|
assert "PackageB" not in updated |
||||
|
print("✓ Passed: Package correctly removed from all sections\n") |
||||
|
|
||||
|
|
||||
|
def test_remove_then_add_same_version(): |
||||
|
"""Test: PR1 removes v1.0, PR2 adds v1.0. Should be removed.""" |
||||
|
print("Test 3: Remove then add same version") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageC": ("1.0.0", "#1")} # removed |
||||
|
) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageC": ("1.0.0", "#2")}, # added back |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageC" not in updated, f"Expected PackageC removed from updated, got: {updated}" |
||||
|
assert "PackageC" not in added, f"Expected PackageC removed from added, got: {added}" |
||||
|
assert "PackageC" not in removed, f"Expected PackageC removed from removed, got: {removed}" |
||||
|
print("✓ Passed: Package correctly removed from all sections\n") |
||||
|
|
||||
|
|
||||
|
def test_add_then_remove_different_version(): |
||||
|
"""Test: PR1 adds v1.0, PR2 removes v2.0. Should show as removed v2.0.""" |
||||
|
print("Test 4: Add then remove different version") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageD": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageD": ("2.0.0", "#2")} # removed different version |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageD" not in added, f"Expected PackageD removed from added, got: {added}" |
||||
|
assert "PackageD" in removed, f"Expected PackageD in removed, got: {removed}" |
||||
|
assert removed["PackageD"][0] == "2.0.0", f"Expected version 2.0.0, got: {removed['PackageD']}" |
||||
|
print(f"✓ Passed: Package correctly tracked as removed with version {removed['PackageD'][0]}\n") |
||||
|
|
||||
|
|
||||
|
def test_update_in_added(): |
||||
|
"""Test: PR1 adds v1.0, PR2 updates to v2.0. Should show as updated 1.0->2.0.""" |
||||
|
print("Test 5: Update a package that was added") |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageE": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{"PackageE": ("1.0.0", "2.0.0", "#2")}, # updated |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageE" not in added, f"Expected PackageE removed from added, got: {added}" |
||||
|
assert "PackageE" in updated, f"Expected PackageE in updated, got: {updated}" |
||||
|
assert updated["PackageE"] == ("1.0.0", "2.0.0", "#1, #2"), \ |
||||
|
f"Expected ('1.0.0', '2.0.0', '#1, #2'), got: {updated['PackageE']}" |
||||
|
print(f"✓ Passed: Package correctly converted to updated: {updated['PackageE']}\n") |
||||
|
|
||||
|
|
||||
|
def test_multiple_updates(): |
||||
|
"""Test: PR1 updates A->B, PR2 updates B->C. Should show A->C.""" |
||||
|
print("Test 6: Multiple updates") |
||||
|
existing = ( |
||||
|
{"PackageF": ("1.0.0", "2.0.0", "#1")}, # updated |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
new = ( |
||||
|
{"PackageF": ("2.0.0", "3.0.0", "#2")}, # updated again |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageF" in updated |
||||
|
assert updated["PackageF"] == ("1.0.0", "3.0.0", "#1, #2"), \ |
||||
|
f"Expected ('1.0.0', '3.0.0', '#1, #2'), got: {updated['PackageF']}" |
||||
|
print(f"✓ Passed: Package correctly shows full range: {updated['PackageF']}\n") |
||||
|
|
||||
|
|
||||
|
def test_multiple_updates_back_to_original(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 updates 3->1. Should be removed.""" |
||||
|
print("Test 7: Multiple updates ending back at original version") |
||||
|
# Simulate PR1 and PR2 already merged |
||||
|
existing = ( |
||||
|
{"PackageG": ("1.0.0", "3.0.0", "#1, #2")}, # updated through PR1 and PR2 |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
# PR3 changes back to 1.0.0 |
||||
|
new = ( |
||||
|
{"PackageG": ("3.0.0", "1.0.0", "#3")}, # updated back to original |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageG" not in updated, f"Expected PackageG removed, got: {updated}" |
||||
|
assert len(added) == 0 and len(removed) == 0 |
||||
|
print("✓ Passed: Package correctly removed (version returned to original)\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_add_same_version(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v3. Should show updated 1->3.""" |
||||
|
print("Test 8: Update-Update-Remove-Add same version") |
||||
|
# After PR1, PR2, PR3 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageH": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR4 adds back the same version that was removed |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageH": ("3.0.0", "#4")}, # added |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageH" in updated, f"Expected PackageH in updated, got: updated={updated}, added={added}, removed={removed}" |
||||
|
assert updated["PackageH"] == ("1.0.0", "3.0.0", "#1, #2, #3, #4"), \ |
||||
|
f"Expected ('1.0.0', '3.0.0', '#1, #2, #3, #4'), got: {updated['PackageH']}" |
||||
|
print(f"✓ Passed: Package correctly shows as updated: {updated['PackageH']}\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_add_original_version(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v1. Should be removed.""" |
||||
|
print("Test 9: Update-Update-Remove-Add original version") |
||||
|
# After PR1, PR2, PR3 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageI": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR4 adds back the original version |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageI": ("1.0.0", "#4")}, # added back to original |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageI" not in updated, f"Expected PackageI removed, got: updated={updated}" |
||||
|
assert "PackageI" not in added, f"Expected PackageI removed, got: added={added}" |
||||
|
assert "PackageI" not in removed, f"Expected PackageI removed, got: removed={removed}" |
||||
|
print("✓ Passed: Package correctly removed (added back to original version)\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_add_different_version(): |
||||
|
"""Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v4. Should show updated 1->4.""" |
||||
|
print("Test 10: Update-Update-Remove-Add different version") |
||||
|
# After PR1, PR2, PR3 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageJ": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR4 adds a completely different version |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageJ": ("4.0.0", "#4")}, # added new version |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageJ" in updated, f"Expected PackageJ in updated, got: updated={updated}, added={added}, removed={removed}" |
||||
|
assert updated["PackageJ"] == ("1.0.0", "4.0.0", "#1, #2, #3, #4"), \ |
||||
|
f"Expected ('1.0.0', '4.0.0', '#1, #2, #3, #4'), got: {updated['PackageJ']}" |
||||
|
print(f"✓ Passed: Package correctly shows as updated: {updated['PackageJ']}\n") |
||||
|
|
||||
|
|
||||
|
def test_add_update_remove(): |
||||
|
"""Test: PR1 adds v1, PR2 updates to v2, PR3 removes v2. Should be completely removed.""" |
||||
|
print("Test 11: Add-Update-Remove") |
||||
|
# After PR1 and PR2 |
||||
|
existing = ( |
||||
|
{"PackageK": ("1.0.0", "2.0.0", "#1, #2")}, # updated (was added in PR1, updated in PR2) |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
# PR3 removes v2 |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageK": ("2.0.0", "#3")} # removed |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageK" not in updated, f"Expected PackageK removed from updated, got: {updated}" |
||||
|
assert "PackageK" not in added, f"Expected PackageK removed from added, got: {added}" |
||||
|
assert "PackageK" in removed, f"Expected PackageK in removed, got: {removed}" |
||||
|
# The removed should track from the original first version |
||||
|
assert removed["PackageK"][0] == "1.0.0", f"Expected removed from 1.0.0, got: {removed['PackageK']}" |
||||
|
print(f"✓ Passed: Package correctly shows as removed from original: {removed['PackageK']}\n") |
||||
|
|
||||
|
|
||||
|
def test_add_remove_add_same_version(): |
||||
|
"""Test: PR1 adds v1, PR2 removes v1, PR3 adds v1 again. Should show as added v1.""" |
||||
|
print("Test 12: Add-Remove-Add same version") |
||||
|
# After PR1 and PR2 (added then removed) |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{} # Completely removed after PR2 |
||||
|
) |
||||
|
# PR3 adds v1 again |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageL": ("1.0.0", "#3")}, # added |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageL" in added, f"Expected PackageL in added, got: added={added}" |
||||
|
assert added["PackageL"] == ("1.0.0", "#3"), f"Expected ('1.0.0', '#3'), got: {added['PackageL']}" |
||||
|
print(f"✓ Passed: Package correctly shows as added: {added['PackageL']}\n") |
||||
|
|
||||
|
|
||||
|
def test_update_remove_remove(): |
||||
|
"""Test: PR1 updates 1->2, PR2 removes v2, PR3 tries to remove again. Should show removed from v1.""" |
||||
|
print("Test 13: Update-Remove (duplicate remove)") |
||||
|
# After PR1 and PR2 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageM": ("1.0.0", "#1, #2")} # removed (original was 1.0.0) |
||||
|
) |
||||
|
# PR3 tries to remove again (edge case, might not happen in practice) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{}, |
||||
|
{"PackageM": ("1.0.0", "#3")} # removed again |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageM" in removed, f"Expected PackageM in removed, got: {removed}" |
||||
|
# Should keep the original information |
||||
|
assert removed["PackageM"][0] == "1.0.0", f"Expected removed from 1.0.0, got: {removed['PackageM']}" |
||||
|
print(f"✓ Passed: Package correctly maintains removed state: {removed['PackageM']}\n") |
||||
|
|
||||
|
|
||||
|
def test_add_add(): |
||||
|
"""Test: PR1 adds v1, PR2 adds v2 (version changed externally). Should show added v2.""" |
||||
|
print("Test 14: Add-Add (version changed between PRs)") |
||||
|
# After PR1 |
||||
|
existing = ( |
||||
|
{}, |
||||
|
{"PackageN": ("1.0.0", "#1")}, # added |
||||
|
{} |
||||
|
) |
||||
|
# PR2 adds different version (edge case) |
||||
|
new = ( |
||||
|
{}, |
||||
|
{"PackageN": ("2.0.0", "#2")}, # added different version |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageN" in added, f"Expected PackageN in added, got: {added}" |
||||
|
assert added["PackageN"][0] == "2.0.0", f"Expected version 2.0.0, got: {added['PackageN']}" |
||||
|
print(f"✓ Passed: Package correctly shows latest added version: {added['PackageN']}\n") |
||||
|
|
||||
|
|
||||
|
def test_complex_chain_ending_in_original(): |
||||
|
"""Test: Complex chain - Add v1, Update to v2, Remove, Add v2, Update to v1. Should be removed.""" |
||||
|
print("Test 15: Complex chain ending at nothing changed") |
||||
|
# After PR1 (add), PR2 (update), PR3 (remove), PR4 (add back) |
||||
|
existing = ( |
||||
|
{"PackageO": ("1.0.0", "2.0.0", "#1, #2, #3, #4")}, # Complex history |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
# PR5 updates back to v1 (original from perspective of first state) |
||||
|
new = ( |
||||
|
{"PackageO": ("2.0.0", "1.0.0", "#5")}, # back to start |
||||
|
{}, |
||||
|
{} |
||||
|
) |
||||
|
updated, added, removed = merge_changes(existing, new) |
||||
|
assert "PackageO" not in updated, f"Expected PackageO removed, got: {updated}" |
||||
|
print(f"✓ Passed: Complex chain correctly removed when ending at original\n") |
||||
|
|
||||
|
|
||||
|
def test_document_format(): |
||||
|
"""Test: Verify the document rendering format.""" |
||||
|
print("Test 16: Document format validation") |
||||
|
|
||||
|
updated = { |
||||
|
"Microsoft.Extensions.Logging": ("8.0.0", "8.0.1", "#123"), |
||||
|
"Newtonsoft.Json": ("13.0.1", "13.0.3", "#456, #789"), |
||||
|
} |
||||
|
|
||||
|
added = { |
||||
|
"Azure.Identity": ("1.10.0", "#567"), |
||||
|
} |
||||
|
|
||||
|
removed = { |
||||
|
"System.Text.Json": ("7.0.0", "#890"), |
||||
|
} |
||||
|
|
||||
|
document = render_section("9.0.0", updated, added, removed) |
||||
|
|
||||
|
# Verify document structure |
||||
|
assert "## 9.0.0" in document, "Version header missing" |
||||
|
assert "| Package | Old Version | New Version | PR |" in document, "Updated table header missing" |
||||
|
assert "Microsoft.Extensions.Logging" in document, "Updated package missing" |
||||
|
assert "**Added:**" in document, "Added section missing" |
||||
|
assert "Azure.Identity" in document, "Added package missing" |
||||
|
assert "**Removed:**" in document, "Removed section missing" |
||||
|
assert "System.Text.Json" in document, "Removed package missing" |
||||
|
|
||||
|
print("✓ Passed: Document format is correct") |
||||
|
print("\nSample output:") |
||||
|
print("-" * 60) |
||||
|
print(document) |
||||
|
print("-" * 60 + "\n") |
||||
|
|
||||
|
|
||||
|
def run_all_tests(): |
||||
|
"""Run all test cases.""" |
||||
|
print("=" * 70) |
||||
|
print("Testing update_dependency_changes.py") |
||||
|
print("=" * 70 + "\n") |
||||
|
|
||||
|
test_update_then_revert() |
||||
|
test_add_then_remove_same_version() |
||||
|
test_remove_then_add_same_version() |
||||
|
test_add_then_remove_different_version() |
||||
|
test_update_in_added() |
||||
|
test_multiple_updates() |
||||
|
test_multiple_updates_back_to_original() |
||||
|
test_update_remove_add_same_version() |
||||
|
test_update_remove_add_original_version() |
||||
|
test_update_remove_add_different_version() |
||||
|
test_add_update_remove() |
||||
|
test_add_remove_add_same_version() |
||||
|
test_update_remove_remove() |
||||
|
test_add_add() |
||||
|
test_complex_chain_ending_in_original() |
||||
|
test_document_format() |
||||
|
|
||||
|
print("=" * 70) |
||||
|
print("All 16 tests passed! ✓") |
||||
|
print("=" * 70) |
||||
|
print("\nTest coverage summary:") |
||||
|
print(" ✓ Basic scenarios (update, add, remove)") |
||||
|
print(" ✓ Version revert handling") |
||||
|
print(" ✓ Complex multi-step sequences") |
||||
|
print(" ✓ Edge cases and duplicates") |
||||
|
print(" ✓ Document format validation") |
||||
|
print("=" * 70) |
||||
|
|
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
run_all_tests() |
||||
@ -0,0 +1,331 @@ |
|||||
|
import subprocess |
||||
|
import re |
||||
|
import os |
||||
|
import sys |
||||
|
import xml.etree.ElementTree as ET |
||||
|
|
||||
|
|
||||
|
HEADER = "# Package Version Changes\n" |
||||
|
DOC_PATH = os.environ.get("DOC_PATH", "docs/en/package-version-changes.md") |
||||
|
|
||||
|
|
||||
|
def get_version(): |
||||
|
"""Read the current version from common.props.""" |
||||
|
try: |
||||
|
tree = ET.parse("common.props") |
||||
|
root = tree.getroot() |
||||
|
version_elem = root.find(".//Version") |
||||
|
if version_elem is not None: |
||||
|
return version_elem.text |
||||
|
except FileNotFoundError: |
||||
|
print("Error: 'common.props' file not found.", file=sys.stderr) |
||||
|
except ET.ParseError as ex: |
||||
|
print(f"Error: Failed to parse 'common.props': {ex}", file=sys.stderr) |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def get_diff(base_ref): |
||||
|
"""Get diff of Directory.Packages.props against the base branch.""" |
||||
|
result = subprocess.run( |
||||
|
["git", "diff", f"origin/{base_ref}", "--", "Directory.Packages.props"], |
||||
|
capture_output=True, |
||||
|
text=True, |
||||
|
) |
||||
|
if result.returncode != 0: |
||||
|
raise RuntimeError( |
||||
|
f"Failed to get diff for base ref 'origin/{base_ref}': {result.stderr}" |
||||
|
) |
||||
|
return result.stdout |
||||
|
|
||||
|
|
||||
|
def get_existing_doc_from_base(base_ref): |
||||
|
"""Read the existing document from the base branch.""" |
||||
|
result = subprocess.run( |
||||
|
["git", "show", f"origin/{base_ref}:{DOC_PATH}"], |
||||
|
capture_output=True, |
||||
|
text=True, |
||||
|
) |
||||
|
if result.returncode == 0: |
||||
|
return result.stdout |
||||
|
return "" |
||||
|
|
||||
|
|
||||
|
def parse_diff_packages(lines, prefix): |
||||
|
"""Parse package versions from diff lines with the given prefix (+ or -).""" |
||||
|
packages = {} |
||||
|
# Use separate patterns to handle different attribute orders |
||||
|
include_pattern = re.compile(r'Include="([^"]+)"') |
||||
|
version_pattern = re.compile(r'Version="([^"]+)"') |
||||
|
for line in lines: |
||||
|
if line.startswith(prefix) and "PackageVersion" in line and not line.startswith(prefix * 3): |
||||
|
include_match = include_pattern.search(line) |
||||
|
version_match = version_pattern.search(line) |
||||
|
if include_match and version_match: |
||||
|
packages[include_match.group(1)] = version_match.group(1) |
||||
|
return packages |
||||
|
|
||||
|
|
||||
|
def classify_changes(old_packages, new_packages, pr_number): |
||||
|
"""Classify diff into updated, added, and removed with PR attribution.""" |
||||
|
updated = {} |
||||
|
added = {} |
||||
|
removed = {} |
||||
|
|
||||
|
all_packages = sorted(set(list(old_packages.keys()) + list(new_packages.keys()))) |
||||
|
|
||||
|
for pkg in all_packages: |
||||
|
if pkg in old_packages and pkg in new_packages: |
||||
|
if old_packages[pkg] != new_packages[pkg]: |
||||
|
updated[pkg] = (old_packages[pkg], new_packages[pkg], pr_number) |
||||
|
elif pkg in new_packages: |
||||
|
added[pkg] = (new_packages[pkg], pr_number) |
||||
|
else: |
||||
|
removed[pkg] = (old_packages[pkg], pr_number) |
||||
|
|
||||
|
return updated, added, removed |
||||
|
|
||||
|
|
||||
|
def parse_existing_section(section_text): |
||||
|
"""Parse an existing markdown section to extract package records with PR info.""" |
||||
|
updated = {} |
||||
|
added = {} |
||||
|
removed = {} |
||||
|
|
||||
|
mode = "updated" |
||||
|
for line in section_text.split("\n"): |
||||
|
if "**Added:**" in line: |
||||
|
mode = "added" |
||||
|
continue |
||||
|
if "**Removed:**" in line: |
||||
|
mode = "removed" |
||||
|
continue |
||||
|
if not line.startswith("|") or line.startswith("| Package") or line.startswith("|---"): |
||||
|
continue |
||||
|
|
||||
|
parts = [p.strip() for p in line.split("|")[1:-1]] |
||||
|
if mode == "updated" and len(parts) >= 3: |
||||
|
pr = parts[3] if len(parts) >= 4 else "" |
||||
|
updated[parts[0]] = (parts[1], parts[2], pr) |
||||
|
elif len(parts) >= 2: |
||||
|
pr = parts[2] if len(parts) >= 3 else "" |
||||
|
if mode == "added": |
||||
|
added[parts[0]] = (parts[1], pr) |
||||
|
else: |
||||
|
removed[parts[0]] = (parts[1], pr) |
||||
|
|
||||
|
return updated, added, removed |
||||
|
|
||||
|
|
||||
|
def merge_prs(existing_pr, new_pr): |
||||
|
"""Merge PR numbers, avoiding duplicates.""" |
||||
|
if not existing_pr or not existing_pr.strip(): |
||||
|
return new_pr |
||||
|
if not new_pr or not new_pr.strip(): |
||||
|
return existing_pr |
||||
|
|
||||
|
# Parse existing PRs |
||||
|
existing_prs = [p.strip() for p in existing_pr.split(",") if p.strip()] |
||||
|
# Add new PR if not already present |
||||
|
if new_pr not in existing_prs: |
||||
|
existing_prs.append(new_pr) |
||||
|
return ", ".join(existing_prs) |
||||
|
|
||||
|
|
||||
|
def merge_changes(existing, new): |
||||
|
"""Merge new changes into existing records for the same version.""" |
||||
|
ex_updated, ex_added, ex_removed = existing |
||||
|
new_updated, new_added, new_removed = new |
||||
|
|
||||
|
merged_updated = dict(ex_updated) |
||||
|
merged_added = dict(ex_added) |
||||
|
merged_removed = dict(ex_removed) |
||||
|
|
||||
|
for pkg, (old_ver, new_ver, pr) in new_updated.items(): |
||||
|
if pkg in merged_updated: |
||||
|
existing_old_ver, existing_new_ver, existing_pr = merged_updated[pkg] |
||||
|
merged_pr = merge_prs(existing_pr, pr) |
||||
|
merged_updated[pkg] = (existing_old_ver, new_ver, merged_pr) |
||||
|
elif pkg in merged_added: |
||||
|
existing_ver, existing_pr = merged_added[pkg] |
||||
|
merged_pr = merge_prs(existing_pr, pr) |
||||
|
# Convert added to updated since the version changed again |
||||
|
del merged_added[pkg] |
||||
|
merged_updated[pkg] = (existing_ver, new_ver, merged_pr) |
||||
|
else: |
||||
|
merged_updated[pkg] = (old_ver, new_ver, pr) |
||||
|
|
||||
|
for pkg, (ver, pr) in new_added.items(): |
||||
|
if pkg in merged_removed: |
||||
|
removed_ver, removed_pr = merged_removed.pop(pkg) |
||||
|
merged_pr = merge_prs(removed_pr, pr) |
||||
|
merged_updated[pkg] = (removed_ver, ver, merged_pr) |
||||
|
elif pkg in merged_added: |
||||
|
existing_ver, existing_pr = merged_added[pkg] |
||||
|
merged_pr = merge_prs(existing_pr, pr) |
||||
|
merged_added[pkg] = (ver, merged_pr) |
||||
|
else: |
||||
|
merged_added[pkg] = (ver, pr) |
||||
|
|
||||
|
for pkg, (ver, pr) in new_removed.items(): |
||||
|
if pkg in merged_added: |
||||
|
existing_ver, existing_pr = merged_added[pkg] |
||||
|
# Only delete if versions match (added then removed the same version) |
||||
|
if existing_ver == ver: |
||||
|
del merged_added[pkg] |
||||
|
else: |
||||
|
# Version changed between add and remove, convert to updated then removed |
||||
|
del merged_added[pkg] |
||||
|
merged_removed[pkg] = (ver, merge_prs(existing_pr, pr)) |
||||
|
elif pkg in merged_updated: |
||||
|
old_ver, new_ver, existing_pr = merged_updated.pop(pkg) |
||||
|
merged_pr = merge_prs(existing_pr, pr) |
||||
|
# Only keep as removed if the final state is different from original |
||||
|
merged_removed[pkg] = (old_ver, merged_pr) |
||||
|
else: |
||||
|
merged_removed[pkg] = (ver, pr) |
||||
|
|
||||
|
# Remove updated entries where old and new versions are the same |
||||
|
merged_updated = {k: v for k, v in merged_updated.items() if v[0] != v[1]} |
||||
|
|
||||
|
# Remove added entries that are also in removed with the same version |
||||
|
for pkg in list(merged_added.keys()): |
||||
|
if pkg in merged_removed: |
||||
|
added_ver, added_pr = merged_added[pkg] |
||||
|
removed_ver, removed_pr = merged_removed[pkg] |
||||
|
if added_ver == removed_ver: |
||||
|
# Package was added and removed at the same version, cancel out |
||||
|
del merged_added[pkg] |
||||
|
del merged_removed[pkg] |
||||
|
|
||||
|
return merged_updated, merged_added, merged_removed |
||||
|
|
||||
|
|
||||
|
def render_section(version, updated, added, removed): |
||||
|
"""Render a version section as markdown.""" |
||||
|
lines = [f"## {version}\n"] |
||||
|
|
||||
|
if updated: |
||||
|
lines.append("| Package | Old Version | New Version | PR |") |
||||
|
lines.append("|---------|-------------|-------------|-----|") |
||||
|
for pkg in sorted(updated): |
||||
|
old_ver, new_ver, pr = updated[pkg] |
||||
|
lines.append(f"| {pkg} | {old_ver} | {new_ver} | {pr} |") |
||||
|
lines.append("") |
||||
|
|
||||
|
if added: |
||||
|
lines.append("**Added:**\n") |
||||
|
lines.append("| Package | Version | PR |") |
||||
|
lines.append("|---------|---------|-----|") |
||||
|
for pkg in sorted(added): |
||||
|
ver, pr = added[pkg] |
||||
|
lines.append(f"| {pkg} | {ver} | {pr} |") |
||||
|
lines.append("") |
||||
|
|
||||
|
if removed: |
||||
|
lines.append("**Removed:**\n") |
||||
|
lines.append("| Package | Version | PR |") |
||||
|
lines.append("|---------|---------|-----|") |
||||
|
for pkg in sorted(removed): |
||||
|
ver, pr = removed[pkg] |
||||
|
lines.append(f"| {pkg} | {ver} | {pr} |") |
||||
|
lines.append("") |
||||
|
|
||||
|
return "\n".join(lines) |
||||
|
|
||||
|
|
||||
|
def parse_document(content): |
||||
|
"""Split document into a list of (version, section_text) tuples.""" |
||||
|
sections = [] |
||||
|
current_version = None |
||||
|
current_lines = [] |
||||
|
|
||||
|
for line in content.split("\n"): |
||||
|
match = re.match(r"^## (.+)$", line) |
||||
|
if match: |
||||
|
if current_version: |
||||
|
sections.append((current_version, "\n".join(current_lines))) |
||||
|
current_version = match.group(1).strip() |
||||
|
current_lines = [line] |
||||
|
elif current_version: |
||||
|
current_lines.append(line) |
||||
|
|
||||
|
if current_version: |
||||
|
sections.append((current_version, "\n".join(current_lines))) |
||||
|
|
||||
|
return sections |
||||
|
|
||||
|
|
||||
|
def main(): |
||||
|
if len(sys.argv) < 3: |
||||
|
print("Usage: update_dependency_changes.py <base-ref> <pr-number>") |
||||
|
sys.exit(1) |
||||
|
|
||||
|
base_ref = sys.argv[1] |
||||
|
pr_arg = sys.argv[2] |
||||
|
|
||||
|
# Validate PR number is numeric |
||||
|
if not re.fullmatch(r"\d+", pr_arg): |
||||
|
print("Invalid PR number; must be numeric.") |
||||
|
sys.exit(1) |
||||
|
|
||||
|
# Validate base_ref doesn't contain dangerous characters |
||||
|
if not re.fullmatch(r"[a-zA-Z0-9/_.-]+", base_ref): |
||||
|
print("Invalid base ref; contains invalid characters.") |
||||
|
sys.exit(1) |
||||
|
|
||||
|
pr_number = f"#{pr_arg}" |
||||
|
|
||||
|
version = get_version() |
||||
|
if not version: |
||||
|
print("Could not read version from common.props.") |
||||
|
sys.exit(1) |
||||
|
|
||||
|
diff = get_diff(base_ref) |
||||
|
if not diff: |
||||
|
print("No diff found for Directory.Packages.props.") |
||||
|
sys.exit(0) |
||||
|
|
||||
|
diff_lines = diff.split("\n") |
||||
|
old_packages = parse_diff_packages(diff_lines, "-") |
||||
|
new_packages = parse_diff_packages(diff_lines, "+") |
||||
|
|
||||
|
new_updated, new_added, new_removed = classify_changes(old_packages, new_packages, pr_number) |
||||
|
|
||||
|
if not new_updated and not new_added and not new_removed: |
||||
|
print("No package version changes detected.") |
||||
|
sys.exit(0) |
||||
|
|
||||
|
# Load existing document from the base branch |
||||
|
existing_content = get_existing_doc_from_base(base_ref) |
||||
|
sections = parse_document(existing_content) if existing_content else [] |
||||
|
|
||||
|
# Find existing section for this version |
||||
|
version_index = None |
||||
|
for i, (v, _) in enumerate(sections): |
||||
|
if v == version: |
||||
|
version_index = i |
||||
|
break |
||||
|
|
||||
|
if version_index is not None: |
||||
|
existing = parse_existing_section(sections[version_index][1]) |
||||
|
merged = merge_changes(existing, (new_updated, new_added, new_removed)) |
||||
|
section_text = render_section(version, *merged) |
||||
|
sections[version_index] = (version, section_text) |
||||
|
else: |
||||
|
section_text = render_section(version, new_updated, new_added, new_removed) |
||||
|
sections.insert(0, (version, section_text)) |
||||
|
|
||||
|
# Write document |
||||
|
doc_dir = os.path.dirname(DOC_PATH) |
||||
|
if doc_dir: |
||||
|
os.makedirs(doc_dir, exist_ok=True) |
||||
|
with open(DOC_PATH, "w") as f: |
||||
|
f.write(HEADER + "\n") |
||||
|
for _, text in sections: |
||||
|
f.write(text.rstrip("\n") + "\n\n") |
||||
|
|
||||
|
print(f"Updated {DOC_PATH} for version {version}") |
||||
|
|
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
main() |
||||
@ -0,0 +1,71 @@ |
|||||
|
# Automatically detects and documents NuGet package version changes in PRs. |
||||
|
# Triggers on changes to Directory.Packages.props and: |
||||
|
# - Adds 'dependency-change' label to the PR |
||||
|
# - Updates docs/en/package-version-changes.md with version changes |
||||
|
# - Commits the documentation back to the PR branch |
||||
|
# Note: Only runs for PRs from the same repository (not forks) to ensure write permissions. |
||||
|
name: Nuget Packages Version Change Detector |
||||
|
|
||||
|
on: |
||||
|
pull_request: |
||||
|
paths: |
||||
|
- 'Directory.Packages.props' |
||||
|
types: |
||||
|
- opened |
||||
|
- synchronize |
||||
|
- reopened |
||||
|
- ready_for_review |
||||
|
|
||||
|
permissions: |
||||
|
contents: read |
||||
|
|
||||
|
concurrency: |
||||
|
group: dependency-changes-${{ github.event.pull_request.number }} |
||||
|
cancel-in-progress: false |
||||
|
|
||||
|
jobs: |
||||
|
label: |
||||
|
if: ${{ !github.event.pull_request.draft && !startsWith(github.head_ref, 'auto-merge/') && github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.head_commit.message, '[skip ci]') }} |
||||
|
permissions: |
||||
|
contents: write |
||||
|
pull-requests: write |
||||
|
runs-on: ubuntu-latest |
||||
|
env: |
||||
|
DOC_PATH: docs/en/package-version-changes.md |
||||
|
steps: |
||||
|
- run: gh pr edit "$PR_NUMBER" --add-label "dependency-change" |
||||
|
env: |
||||
|
PR_NUMBER: ${{ github.event.pull_request.number }} |
||||
|
GH_TOKEN: ${{ secrets.MLM_Token }} |
||||
|
GH_REPO: ${{ github.repository }} |
||||
|
|
||||
|
- uses: actions/checkout@v4 |
||||
|
with: |
||||
|
ref: ${{ github.event.pull_request.head.ref }} |
||||
|
fetch-depth: 1 |
||||
|
|
||||
|
- name: Fetch base branch |
||||
|
run: git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} --depth=1 |
||||
|
|
||||
|
- uses: actions/setup-python@v5 |
||||
|
with: |
||||
|
python-version: '3.x' |
||||
|
|
||||
|
- run: python .github/scripts/update_dependency_changes.py ${{ github.event.pull_request.base.ref }} ${{ github.event.pull_request.number }} |
||||
|
|
||||
|
- name: Commit changes |
||||
|
run: | |
||||
|
set -e |
||||
|
git config user.name "github-actions[bot]" |
||||
|
git config user.email "github-actions[bot]@users.noreply.github.com" |
||||
|
git add "$DOC_PATH" |
||||
|
if git diff --staged --quiet; then |
||||
|
echo "No changes to commit." |
||||
|
else |
||||
|
git commit -m "docs: update package version changes [skip ci]" |
||||
|
if ! git push; then |
||||
|
echo "Error: Failed to push changes. This may be due to conflicts or permission issues." |
||||
|
exit 1 |
||||
|
fi |
||||
|
echo "Successfully committed and pushed documentation changes." |
||||
|
fi |
||||
@ -0,0 +1,658 @@ |
|||||
|
name: Update ABP Studio Docs |
||||
|
|
||||
|
on: |
||||
|
repository_dispatch: |
||||
|
types: [update_studio_docs] |
||||
|
workflow_dispatch: |
||||
|
inputs: |
||||
|
version: |
||||
|
description: 'Studio version (e.g., 2.1.10)' |
||||
|
required: true |
||||
|
name: |
||||
|
description: 'Release name' |
||||
|
required: true |
||||
|
notes: |
||||
|
description: 'Raw release notes' |
||||
|
required: true |
||||
|
url: |
||||
|
description: 'Release URL' |
||||
|
required: true |
||||
|
target_branch: |
||||
|
description: 'Target branch (default: dev)' |
||||
|
required: false |
||||
|
default: 'dev' |
||||
|
|
||||
|
jobs: |
||||
|
update-docs: |
||||
|
runs-on: ubuntu-latest |
||||
|
permissions: |
||||
|
contents: write |
||||
|
pull-requests: write |
||||
|
models: read |
||||
|
|
||||
|
steps: |
||||
|
# ------------------------------------------------- |
||||
|
# Extract payload (repository_dispatch or workflow_dispatch) |
||||
|
# ------------------------------------------------- |
||||
|
- name: Extract payload |
||||
|
id: payload |
||||
|
run: | |
||||
|
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then |
||||
|
echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT |
||||
|
echo "name=${{ github.event.client_payload.name }}" >> $GITHUB_OUTPUT |
||||
|
echo "url=${{ github.event.client_payload.url }}" >> $GITHUB_OUTPUT |
||||
|
echo "target_branch=${{ github.event.client_payload.target_branch || 'dev' }}" >> $GITHUB_OUTPUT |
||||
|
|
||||
|
# Save notes to environment variable (multiline) |
||||
|
{ |
||||
|
echo "RAW_NOTES<<NOTES_DELIMITER_EOF" |
||||
|
jq -r '.client_payload.notes' "$GITHUB_EVENT_PATH" |
||||
|
echo "NOTES_DELIMITER_EOF" |
||||
|
} >> $GITHUB_ENV |
||||
|
else |
||||
|
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT |
||||
|
echo "name=${{ github.event.inputs.name }}" >> $GITHUB_OUTPUT |
||||
|
echo "url=${{ github.event.inputs.url }}" >> $GITHUB_OUTPUT |
||||
|
echo "target_branch=${{ github.event.inputs.target_branch || 'dev' }}" >> $GITHUB_OUTPUT |
||||
|
|
||||
|
# Save notes to environment variable (multiline) |
||||
|
{ |
||||
|
echo "RAW_NOTES<<NOTES_DELIMITER_EOF" |
||||
|
echo "${{ github.event.inputs.notes }}" |
||||
|
echo "NOTES_DELIMITER_EOF" |
||||
|
} >> $GITHUB_ENV |
||||
|
fi |
||||
|
|
||||
|
- name: Validate payload |
||||
|
env: |
||||
|
VERSION: ${{ steps.payload.outputs.version }} |
||||
|
NAME: ${{ steps.payload.outputs.name }} |
||||
|
URL: ${{ steps.payload.outputs.url }} |
||||
|
TARGET_BRANCH: ${{ steps.payload.outputs.target_branch }} |
||||
|
run: | |
||||
|
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then |
||||
|
echo "❌ Missing: version" |
||||
|
exit 1 |
||||
|
fi |
||||
|
if [ -z "$NAME" ] || [ "$NAME" = "null" ]; then |
||||
|
echo "❌ Missing: name" |
||||
|
exit 1 |
||||
|
fi |
||||
|
if [ -z "$URL" ] || [ "$URL" = "null" ]; then |
||||
|
echo "❌ Missing: url" |
||||
|
exit 1 |
||||
|
fi |
||||
|
if [ -z "$RAW_NOTES" ]; then |
||||
|
echo "❌ Missing: release notes" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
echo "✅ Payload validated" |
||||
|
echo " Version: $VERSION" |
||||
|
echo " Name: $NAME" |
||||
|
echo " Target Branch: $TARGET_BRANCH" |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Checkout target branch |
||||
|
# ------------------------------------------------- |
||||
|
- name: Checkout |
||||
|
uses: actions/checkout@v4 |
||||
|
with: |
||||
|
ref: ${{ steps.payload.outputs.target_branch }} |
||||
|
fetch-depth: 0 |
||||
|
|
||||
|
- name: Configure git |
||||
|
run: | |
||||
|
git config user.name "github-actions[bot]" |
||||
|
git config user.email "github-actions[bot]@users.noreply.github.com" |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Create working branch |
||||
|
# ------------------------------------------------- |
||||
|
- name: Create branch |
||||
|
env: |
||||
|
VERSION: ${{ steps.payload.outputs.version }} |
||||
|
run: | |
||||
|
BRANCH="docs/studio-${VERSION}" |
||||
|
|
||||
|
# Delete remote branch if exists (idempotent) |
||||
|
git push origin --delete "$BRANCH" 2>/dev/null || true |
||||
|
|
||||
|
git checkout -B "$BRANCH" |
||||
|
echo "BRANCH=$BRANCH" >> $GITHUB_ENV |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Analyze existing release notes format |
||||
|
# ------------------------------------------------- |
||||
|
- name: Analyze existing format |
||||
|
id: analyze |
||||
|
run: | |
||||
|
FILE="docs/en/studio/release-notes.md" |
||||
|
|
||||
|
if [ -f "$FILE" ] && [ -s "$FILE" ]; then |
||||
|
{ |
||||
|
echo "EXISTING_FORMAT<<DELIMITER_EOF" |
||||
|
head -50 "$FILE" | sed 's/DELIMITER_EOF/DELIMITER_E0F/g' |
||||
|
echo "DELIMITER_EOF" |
||||
|
} >> $GITHUB_OUTPUT |
||||
|
else |
||||
|
{ |
||||
|
echo "EXISTING_FORMAT<<DELIMITER_EOF" |
||||
|
echo "# ABP Studio Release Notes" |
||||
|
echo "" |
||||
|
echo "## 2.1.0 (2025-12-08) Latest" |
||||
|
echo "- Enhanced Module Installation UI" |
||||
|
echo "- Added AI Management option" |
||||
|
echo "DELIMITER_EOF" |
||||
|
} >> $GITHUB_OUTPUT |
||||
|
fi |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Try AI formatting (OPTIONAL - never fails workflow) |
||||
|
# ------------------------------------------------- |
||||
|
- name: Format release notes with AI |
||||
|
id: ai |
||||
|
continue-on-error: true |
||||
|
uses: actions/ai-inference@v1 |
||||
|
with: |
||||
|
model: openai/gpt-4.1 |
||||
|
prompt: | |
||||
|
You are a technical writer for ABP Studio release notes. |
||||
|
|
||||
|
Existing release notes format: |
||||
|
${{ steps.analyze.outputs.EXISTING_FORMAT }} |
||||
|
|
||||
|
New release: |
||||
|
Version: ${{ steps.payload.outputs.version }} |
||||
|
Name: ${{ steps.payload.outputs.name }} |
||||
|
Raw notes: |
||||
|
${{ env.RAW_NOTES }} |
||||
|
|
||||
|
CRITICAL RULES: |
||||
|
1. Extract ONLY essential, user-facing changes |
||||
|
2. Format as bullet points starting with "- " |
||||
|
3. Keep it concise and professional |
||||
|
4. Match the style of existing release notes |
||||
|
5. Skip internal/technical details unless critical |
||||
|
6. Return ONLY the bullet points (no version header, no date) |
||||
|
7. One change per line |
||||
|
|
||||
|
Output example: |
||||
|
- Fixed books sample for blazor-webapp tiered solution |
||||
|
- Enhanced Module Installation UI |
||||
|
- Added AI Management option to Startup Templates |
||||
|
|
||||
|
Return ONLY the formatted bullet points. |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Fallback: Use raw notes if AI unavailable |
||||
|
# ------------------------------------------------- |
||||
|
- name: Prepare final release notes |
||||
|
run: | |
||||
|
mkdir -p .tmp |
||||
|
|
||||
|
AI_RESPONSE="${{ steps.ai.outputs.response }}" |
||||
|
|
||||
|
if [ -n "$AI_RESPONSE" ] && [ "$AI_RESPONSE" != "null" ]; then |
||||
|
echo "✅ Using AI-formatted release notes" |
||||
|
echo "$AI_RESPONSE" > .tmp/final-notes.txt |
||||
|
else |
||||
|
echo "⚠️ AI unavailable - using aggressive cleaning on raw release notes" |
||||
|
|
||||
|
# Clean and format raw notes with aggressive filtering |
||||
|
echo "$RAW_NOTES" | while IFS= read -r line; do |
||||
|
# Skip empty lines |
||||
|
[ -z "$line" ] && continue |
||||
|
|
||||
|
# Skip section headers |
||||
|
[[ "$line" =~ ^#+.*What.*Changed ]] && continue |
||||
|
[[ "$line" =~ ^##[[:space:]] ]] && continue |
||||
|
|
||||
|
# Skip full changelog links |
||||
|
[[ "$line" =~ ^\*\*Full\ Changelog ]] && continue |
||||
|
[[ "$line" =~ ^Full\ Changelog ]] && continue |
||||
|
|
||||
|
# Remove leading bullet/asterisk |
||||
|
line=$(echo "$line" | sed 's/^[[:space:]]*[*-][[:space:]]*//') |
||||
|
|
||||
|
# Aggressive cleaning: remove entire " by @user in https://..." suffix |
||||
|
line=$(echo "$line" | sed 's/[[:space:]]*by @[a-zA-Z0-9_-]*[[:space:]]*in https:\/\/github\.com\/[^[:space:]]*//g') |
||||
|
|
||||
|
# Remove remaining "by @username" or "by username" |
||||
|
line=$(echo "$line" | sed 's/[[:space:]]*by @[a-zA-Z0-9_-]*[[:space:]]*$//g') |
||||
|
line=$(echo "$line" | sed 's/[[:space:]]*by [a-zA-Z0-9_-]*[[:space:]]*$//g') |
||||
|
|
||||
|
# Remove standalone @mentions |
||||
|
line=$(echo "$line" | sed 's/@[a-zA-Z0-9_-]*//g') |
||||
|
|
||||
|
# Clean trailing periods if orphaned |
||||
|
line=$(echo "$line" | sed 's/\.[[:space:]]*$//') |
||||
|
|
||||
|
# Trim all whitespace |
||||
|
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') |
||||
|
|
||||
|
# Skip if line is empty or too short |
||||
|
[ -z "$line" ] && continue |
||||
|
[ ${#line} -lt 5 ] && continue |
||||
|
|
||||
|
# Capitalize first letter if lowercase |
||||
|
line="$(echo ${line:0:1} | tr '[:lower:]' '[:upper:]')${line:1}" |
||||
|
|
||||
|
# Add clean bullet and output |
||||
|
echo "- $line" |
||||
|
done > .tmp/final-notes.txt |
||||
|
fi |
||||
|
|
||||
|
# Safety check: verify we have content |
||||
|
if [ ! -s .tmp/final-notes.txt ]; then |
||||
|
echo "⚠️ No valid release notes extracted, using minimal fallback" |
||||
|
echo "- Release ${{ steps.payload.outputs.version }}" > .tmp/final-notes.txt |
||||
|
fi |
||||
|
|
||||
|
echo "=== Final release notes ===" |
||||
|
cat .tmp/final-notes.txt |
||||
|
echo "===========================" |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Update release-notes.md (move "Latest" tag correctly) |
||||
|
# ------------------------------------------------- |
||||
|
- name: Update release-notes.md |
||||
|
env: |
||||
|
VERSION: ${{ steps.payload.outputs.version }} |
||||
|
NAME: ${{ steps.payload.outputs.name }} |
||||
|
URL: ${{ steps.payload.outputs.url }} |
||||
|
run: | |
||||
|
FILE="docs/en/studio/release-notes.md" |
||||
|
DATE="$(date +%Y-%m-%d)" |
||||
|
|
||||
|
mkdir -p docs/en/studio |
||||
|
|
||||
|
# Check if version already exists (idempotent) |
||||
|
if [ -f "$FILE" ] && grep -q "^## $VERSION " "$FILE"; then |
||||
|
echo "⚠️ Version $VERSION already exists in release notes - skipping update" |
||||
|
echo "VERSION_UPDATED=false" >> $GITHUB_ENV |
||||
|
exit 0 |
||||
|
fi |
||||
|
|
||||
|
# Read final notes |
||||
|
NOTES_CONTENT="$(cat .tmp/final-notes.txt)" |
||||
|
|
||||
|
# Create new entry |
||||
|
NEW_ENTRY="## $VERSION ($DATE) Latest |
||||
|
|
||||
|
$NOTES_CONTENT |
||||
|
" |
||||
|
|
||||
|
# Process file |
||||
|
if [ ! -f "$FILE" ]; then |
||||
|
# Create new file |
||||
|
cat > "$FILE" <<EOF |
||||
|
# ABP Studio Release Notes |
||||
|
|
||||
|
$NEW_ENTRY |
||||
|
EOF |
||||
|
else |
||||
|
# Remove "Latest" tag from existing entries and insert new one |
||||
|
awk -v new_entry="$NEW_ENTRY" ' |
||||
|
BEGIN { inserted = 0 } |
||||
|
|
||||
|
# Remove "Latest" from existing entries |
||||
|
/^## [0-9]/ { |
||||
|
gsub(/ Latest$/, "", $0) |
||||
|
} |
||||
|
|
||||
|
# Insert after first "## " (version heading) or after title |
||||
|
/^## / && !inserted { |
||||
|
print new_entry |
||||
|
inserted = 1 |
||||
|
} |
||||
|
|
||||
|
# Print current line |
||||
|
{ print } |
||||
|
|
||||
|
# If we reach end without inserting, add at end |
||||
|
END { |
||||
|
if (!inserted) { |
||||
|
print "" |
||||
|
print new_entry |
||||
|
} |
||||
|
} |
||||
|
' "$FILE" > "$FILE.new" |
||||
|
|
||||
|
mv "$FILE.new" "$FILE" |
||||
|
fi |
||||
|
|
||||
|
echo "VERSION_UPDATED=true" >> $GITHUB_ENV |
||||
|
|
||||
|
echo "=== Updated release-notes.md preview ===" |
||||
|
head -30 "$FILE" |
||||
|
echo "========================================" |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Fetch latest stable ABP version (no preview/rc/beta) |
||||
|
# ------------------------------------------------- |
||||
|
- name: Fetch latest stable ABP version |
||||
|
id: abp |
||||
|
run: | |
||||
|
# Fetch all releases |
||||
|
RELEASES=$(curl -fsS \ |
||||
|
-H "Accept: application/vnd.github+json" \ |
||||
|
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ |
||||
|
"https://api.github.com/repos/abpframework/abp/releases?per_page=20") |
||||
|
|
||||
|
# Filter stable releases (exclude preview, rc, beta, dev) |
||||
|
ABP_VERSION=$(echo "$RELEASES" | jq -r ' |
||||
|
[.[] | select( |
||||
|
(.prerelease == false) and |
||||
|
(.tag_name | test("preview|rc|beta|dev"; "i") | not) |
||||
|
)] | first | .tag_name |
||||
|
') |
||||
|
|
||||
|
if [ -z "$ABP_VERSION" ] || [ "$ABP_VERSION" = "null" ]; then |
||||
|
echo "❌ Could not determine latest stable ABP version" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
echo "✅ Latest stable ABP version: $ABP_VERSION" |
||||
|
echo "ABP_VERSION=$ABP_VERSION" >> $GITHUB_ENV |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Update version-mapping.md (smart range expansion) |
||||
|
# ------------------------------------------------- |
||||
|
- name: Update version-mapping.md |
||||
|
env: |
||||
|
STUDIO_VERSION: ${{ steps.payload.outputs.version }} |
||||
|
run: | |
||||
|
FILE="docs/en/studio/version-mapping.md" |
||||
|
ABP_VERSION="${{ env.ABP_VERSION }}" |
||||
|
|
||||
|
mkdir -p docs/en/studio |
||||
|
|
||||
|
# Create file if doesn't exist |
||||
|
if [ ! -f "$FILE" ]; then |
||||
|
cat > "$FILE" <<EOF |
||||
|
# ABP Studio and ABP Startup Template Version Mappings |
||||
|
|
||||
|
| **ABP Studio Version** | **ABP Version of Startup Template** | |
||||
|
|------------------------|-------------------------------------| |
||||
|
| $STUDIO_VERSION | $ABP_VERSION | |
||||
|
EOF |
||||
|
echo "MAPPING_UPDATED=true" >> $GITHUB_ENV |
||||
|
exit 0 |
||||
|
fi |
||||
|
|
||||
|
# Use Python for smart version range handling |
||||
|
python3 <<'PYTHON_EOF' |
||||
|
import os |
||||
|
import re |
||||
|
from packaging.version import Version, InvalidVersion |
||||
|
|
||||
|
studio_ver = os.environ["STUDIO_VERSION"] |
||||
|
abp_ver = os.environ["ABP_VERSION"] |
||||
|
file_path = "docs/en/studio/version-mapping.md" |
||||
|
|
||||
|
try: |
||||
|
studio = Version(studio_ver) |
||||
|
except InvalidVersion: |
||||
|
print(f"❌ Invalid Studio version: {studio_ver}") |
||||
|
exit(1) |
||||
|
|
||||
|
with open(file_path, 'r') as f: |
||||
|
lines = f.readlines() |
||||
|
|
||||
|
# Find table start (skip SEO and headers) |
||||
|
table_start = 0 |
||||
|
table_end = 0 |
||||
|
for i, line in enumerate(lines): |
||||
|
if line.strip().startswith('|') and '**ABP Studio Version**' in line: |
||||
|
table_start = i |
||||
|
elif table_start > 0 and line.strip() and not line.strip().startswith('|'): |
||||
|
table_end = i |
||||
|
break |
||||
|
|
||||
|
if table_start == 0: |
||||
|
print("❌ Could not find version mapping table") |
||||
|
exit(1) |
||||
|
|
||||
|
# If no end found, table goes to end of file |
||||
|
if table_end == 0: |
||||
|
table_end = len(lines) |
||||
|
|
||||
|
# Extract sections |
||||
|
before_table = lines[:table_start] # Everything before table |
||||
|
table_header = lines[table_start:table_start+2] # Header + separator |
||||
|
data_rows = [l for l in lines[table_start+2:table_end] if l.strip().startswith('|')] # Data rows |
||||
|
after_table = lines[table_end:] # Everything after table |
||||
|
|
||||
|
new_rows = [] |
||||
|
handled = False |
||||
|
|
||||
|
def parse_version_range(version_str): |
||||
|
"""Parse '2.1.5 - 2.1.9' or '2.1.5' into (start, end)""" |
||||
|
version_str = version_str.strip() |
||||
|
|
||||
|
if '–' in version_str or '-' in version_str: |
||||
|
# Handle both em-dash and hyphen |
||||
|
parts = re.split(r'\s*[–-]\s*', version_str) |
||||
|
if len(parts) == 2: |
||||
|
try: |
||||
|
return Version(parts[0].strip()), Version(parts[1].strip()) |
||||
|
except InvalidVersion: |
||||
|
return None, None |
||||
|
|
||||
|
try: |
||||
|
v = Version(version_str) |
||||
|
return v, v |
||||
|
except InvalidVersion: |
||||
|
return None, None |
||||
|
|
||||
|
def format_row(studio_range, abp_version): |
||||
|
"""Format a table row with proper spacing""" |
||||
|
return f"| {studio_range:<22} | {abp_version:<27} |\n" |
||||
|
|
||||
|
# Process existing rows |
||||
|
for row in data_rows: |
||||
|
match = re.match(r'\|\s*(.+?)\s*\|\s*(.+?)\s*\|', row) |
||||
|
if not match: |
||||
|
continue |
||||
|
|
||||
|
existing_studio_range = match.group(1).strip() |
||||
|
existing_abp = match.group(2).strip() |
||||
|
|
||||
|
# Only consider rows with matching ABP version |
||||
|
if existing_abp != abp_ver: |
||||
|
new_rows.append(row) |
||||
|
continue |
||||
|
|
||||
|
start_ver, end_ver = parse_version_range(existing_studio_range) |
||||
|
|
||||
|
if start_ver is None or end_ver is None: |
||||
|
new_rows.append(row) |
||||
|
continue |
||||
|
|
||||
|
# Check if current studio version is in this range |
||||
|
if start_ver <= studio <= end_ver: |
||||
|
print(f"✅ Studio version {studio_ver} already covered in range {existing_studio_range}") |
||||
|
handled = True |
||||
|
new_rows.append(row) |
||||
|
|
||||
|
# Check if we should extend the range |
||||
|
elif end_ver < studio: |
||||
|
# Calculate if studio is the next logical version |
||||
|
# For patch versions: 2.1.9 -> 2.1.10 |
||||
|
# For minor versions: 2.1.9 -> 2.2.0 |
||||
|
|
||||
|
# Simple heuristic: if major.minor match and patch increments, extend range |
||||
|
if (start_ver.major == studio.major and |
||||
|
start_ver.minor == studio.minor and |
||||
|
studio.micro <= end_ver.micro + 5): # Allow small gaps |
||||
|
|
||||
|
new_range = f"{start_ver} - {studio}" |
||||
|
new_rows.append(format_row(new_range, abp_ver)) |
||||
|
print(f"✅ Extended range: {new_range}") |
||||
|
handled = True |
||||
|
else: |
||||
|
new_rows.append(row) |
||||
|
else: |
||||
|
new_rows.append(row) |
||||
|
|
||||
|
# If not handled, add new row at top of data |
||||
|
if not handled: |
||||
|
new_row = format_row(str(studio), abp_ver) |
||||
|
new_rows.insert(0, new_row) |
||||
|
print(f"✅ Added new mapping: {studio_ver} -> {abp_ver}") |
||||
|
|
||||
|
# Write updated file - preserve ALL content |
||||
|
with open(file_path, 'w') as f: |
||||
|
f.writelines(before_table) # SEO, title, intro text |
||||
|
f.writelines(table_header) # Table header |
||||
|
f.writelines(new_rows) # Updated data rows |
||||
|
f.writelines(after_table) # Content after table (preview section, etc.) |
||||
|
|
||||
|
print("MAPPING_UPDATED=true") |
||||
|
PYTHON_EOF |
||||
|
|
||||
|
echo "MAPPING_UPDATED=true" >> $GITHUB_ENV |
||||
|
|
||||
|
echo "=== Updated version-mapping.md preview ===" |
||||
|
head -35 "$FILE" |
||||
|
echo "==========================================" |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Check for changes |
||||
|
# ------------------------------------------------- |
||||
|
- name: Check for changes |
||||
|
id: changes |
||||
|
run: | |
||||
|
git add docs/en/studio/ |
||||
|
|
||||
|
if git diff --cached --quiet; then |
||||
|
echo "has_changes=false" >> $GITHUB_OUTPUT |
||||
|
echo "⚠️ No changes detected" |
||||
|
else |
||||
|
echo "has_changes=true" >> $GITHUB_OUTPUT |
||||
|
echo "✅ Changes detected:" |
||||
|
git diff --cached --stat |
||||
|
fi |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Commit & push |
||||
|
# ------------------------------------------------- |
||||
|
- name: Commit and push |
||||
|
if: steps.changes.outputs.has_changes == 'true' |
||||
|
env: |
||||
|
VERSION: ${{ steps.payload.outputs.version }} |
||||
|
NAME: ${{ steps.payload.outputs.name }} |
||||
|
run: | |
||||
|
git commit -m "docs(studio): update documentation for release $VERSION |
||||
|
|
||||
|
- Updated release notes for $VERSION |
||||
|
- Updated version mapping with ABP ${{ env.ABP_VERSION }} |
||||
|
|
||||
|
Release: $NAME" |
||||
|
|
||||
|
git push -f origin "$BRANCH" |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Create or update PR |
||||
|
# ------------------------------------------------- |
||||
|
- name: Create or update PR |
||||
|
if: steps.changes.outputs.has_changes == 'true' |
||||
|
env: |
||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
|
VERSION: ${{ steps.payload.outputs.version }} |
||||
|
NAME: ${{ steps.payload.outputs.name }} |
||||
|
URL: ${{ steps.payload.outputs.url }} |
||||
|
TARGET_BRANCH: ${{ steps.payload.outputs.target_branch }} |
||||
|
run: | |
||||
|
# Check for existing PR |
||||
|
EXISTING_PR=$(gh pr list \ |
||||
|
--head "$BRANCH" \ |
||||
|
--base "$TARGET_BRANCH" \ |
||||
|
--json number \ |
||||
|
--jq '.[0].number' 2>/dev/null || echo "") |
||||
|
|
||||
|
PR_BODY="Automated documentation update for ABP Studio release **$VERSION**. |
||||
|
|
||||
|
## Release Information |
||||
|
- **Version**: $VERSION |
||||
|
- **Name**: $NAME |
||||
|
- **Release**: [View on GitHub]($URL) |
||||
|
- **ABP Framework Version**: ${{ env.ABP_VERSION }} |
||||
|
|
||||
|
## Changes |
||||
|
- ✅ Updated [release-notes.md](docs/en/studio/release-notes.md) |
||||
|
- ✅ Updated [version-mapping.md](docs/en/studio/version-mapping.md) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
*This PR was automatically generated by the [update-studio-docs workflow](.github/workflows/update-studio-docs.yml)*" |
||||
|
|
||||
|
if [ -n "$EXISTING_PR" ]; then |
||||
|
echo "🔄 Updating existing PR #$EXISTING_PR" |
||||
|
|
||||
|
gh pr edit "$EXISTING_PR" \ |
||||
|
--title "docs(studio): release $VERSION - $NAME" \ |
||||
|
--body "$PR_BODY" |
||||
|
|
||||
|
echo "PR_NUMBER=$EXISTING_PR" >> $GITHUB_ENV |
||||
|
else |
||||
|
echo "📝 Creating new PR" |
||||
|
|
||||
|
sleep 2 # Wait for GitHub to sync |
||||
|
|
||||
|
PR_URL=$(gh pr create \ |
||||
|
--title "docs(studio): release $VERSION - $NAME" \ |
||||
|
--body "$PR_BODY" \ |
||||
|
--base "$TARGET_BRANCH" \ |
||||
|
--head "$BRANCH") |
||||
|
|
||||
|
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') |
||||
|
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV |
||||
|
echo "✅ Created PR #$PR_NUMBER: $PR_URL" |
||||
|
fi |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Enable auto-merge (safe with branch protection) |
||||
|
# ------------------------------------------------- |
||||
|
- name: Enable auto-merge |
||||
|
if: steps.changes.outputs.has_changes == 'true' |
||||
|
env: |
||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
|
continue-on-error: true |
||||
|
run: | |
||||
|
echo "🔄 Attempting to enable auto-merge for PR #$PR_NUMBER" |
||||
|
|
||||
|
gh pr merge "$PR_NUMBER" \ |
||||
|
--auto \ |
||||
|
--squash \ |
||||
|
--delete-branch || { |
||||
|
echo "⚠️ Auto-merge not available (branch protection or permissions)" |
||||
|
echo " PR #$PR_NUMBER is ready for manual review" |
||||
|
} |
||||
|
|
||||
|
# ------------------------------------------------- |
||||
|
# Summary |
||||
|
# ------------------------------------------------- |
||||
|
- name: Workflow summary |
||||
|
if: always() |
||||
|
env: |
||||
|
VERSION: ${{ steps.payload.outputs.version }} |
||||
|
run: | |
||||
|
echo "## 📚 ABP Studio Docs Update Summary" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "**Version**: $VERSION" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "**Release**: ${{ steps.payload.outputs.name }}" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "**Target Branch**: ${{ steps.payload.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "" >> $GITHUB_STEP_SUMMARY |
||||
|
|
||||
|
if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then |
||||
|
echo "### ✅ Changes Applied" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- Release notes updated: ${{ env.VERSION_UPDATED }}" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- Version mapping updated: ${{ env.MAPPING_UPDATED }}" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ABP Framework version: ${{ env.ABP_VERSION }}" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- PR: #${{ env.PR_NUMBER }}" >> $GITHUB_STEP_SUMMARY |
||||
|
else |
||||
|
echo "### ⚠️ No Changes" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "Version $VERSION already exists in documentation." >> $GITHUB_STEP_SUMMARY |
||||
|
fi |
||||
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
@ -0,0 +1,82 @@ |
|||||
|
# ABP.IO Platform 10.1 Final Has Been Released! |
||||
|
|
||||
|
We are glad to announce that [ABP](https://abp.io/) 10.1 stable version has been released. |
||||
|
|
||||
|
## What's New With Version 10.1? |
||||
|
|
||||
|
All the new features were explained in detail in the [10.1 RC Announcement Post](https://abp.io/community/announcements/announcing-abp-10-1-release-candidate-cyqui19d), so there is no need to review them again. You can check it out for more details. |
||||
|
|
||||
|
## Getting Started with 10.1 |
||||
|
|
||||
|
### How to Upgrade an Existing Solution |
||||
|
|
||||
|
You can upgrade your existing solutions with either ABP Studio or ABP CLI. In the following sections, both approaches are explained: |
||||
|
|
||||
|
### Upgrading via ABP Studio |
||||
|
|
||||
|
If you are already using the ABP Studio, you can upgrade it to the latest version. ABP Studio periodically checks for updates in the background, and when a new version of ABP Studio is available, you will be notified through a modal. Then, you can update it by confirming the opened modal. See [the documentation](https://abp.io/docs/latest/studio/installation#upgrading) for more info. |
||||
|
|
||||
|
After upgrading the ABP Studio, then you can open your solution in the application, and simply click the **Upgrade ABP Packages** action button to instantly upgrade your solution: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### Upgrading via ABP CLI |
||||
|
|
||||
|
Alternatively, you can upgrade your existing solution via ABP CLI. First, you need to install the ABP CLI or upgrade it to the latest version. |
||||
|
|
||||
|
If you haven't installed it yet, you can run the following command: |
||||
|
|
||||
|
```bash |
||||
|
dotnet tool install -g Volo.Abp.Studio.Cli |
||||
|
``` |
||||
|
|
||||
|
Or to update the existing CLI, you can run the following command: |
||||
|
|
||||
|
```bash |
||||
|
dotnet tool update -g Volo.Abp.Studio.Cli |
||||
|
``` |
||||
|
|
||||
|
After installing/updating the ABP CLI, you can use the [`update` command](https://abp.io/docs/latest/CLI#update) to update all the ABP related NuGet and NPM packages in your solution as follows: |
||||
|
|
||||
|
```bash |
||||
|
abp update |
||||
|
``` |
||||
|
|
||||
|
You can run this command in the root folder of your solution to update all ABP related packages. |
||||
|
|
||||
|
## Migration Guides |
||||
|
|
||||
|
There are a few breaking changes in this version that may affect your application. Please read the migration guide carefully, if you are upgrading from v10.0 or earlier versions: [ABP Version 10.1 Migration Guide](https://abp.io/docs/latest/release-info/migration-guides/abp-10-1) |
||||
|
|
||||
|
## Community News |
||||
|
|
||||
|
### New ABP Community Articles |
||||
|
|
||||
|
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: |
||||
|
|
||||
|
* [Enis Necipoğlu](https://abp.io/community/members/enisn): |
||||
|
* [ABP Framework's Hidden Magic: Things That Just Work Without You Knowing](https://abp.io/community/articles/hidden-magic-things-that-just-work-without-you-knowing-vw6osmyt) |
||||
|
* [Implementing Multiple Global Query Filters with Entity Framework Core](https://abp.io/community/articles/implementing-multiple-global-query-filters-with-entity-ugnsmf6i) |
||||
|
* [Suhaib Mousa](https://abp.io/community/members/suhaib-mousa): |
||||
|
* [.NET 11 Preview 1 Highlights: Faster Runtime, Smarter JIT, and AI-Ready Improvements](https://abp.io/community/articles/dotnet-11-preview-1-highlights-hspp3o5x) |
||||
|
* [TOON vs JSON for LLM Prompts in ABP: Token-Efficient Structured Context](https://abp.io/community/articles/toon-vs-json-b4rn2avd) |
||||
|
* [Fahri Gedik](https://abp.io/community/members/fahrigedik): |
||||
|
* [Building a Multi-Agent AI System with A2A, MCP, and ADK in .NET](https://abp.io/community/articles/building-a-multiagent-ai-system-with-a2a-mcp-iefdehyx) |
||||
|
* [Async Chain of Persistence Pattern: Designing for Failure in Event-Driven Systems](https://abp.io/community/articles/async-chain-of-persistence-pattern-wzjuy4gl) |
||||
|
* [Alper Ebiçoğlu](https://abp.io/community/members/alper): |
||||
|
* [NDC London 2026: From a Developer's Perspective and My Personal Notes about AI](https://abp.io/community/articles/ndc-london-2026-a-.net-conf-from-a-developers-perspective-07wp50yl) |
||||
|
* [Which Open-Source PDF Libraries Are Recently Popular? A Data-Driven Look At PDF Topic](https://abp.io/community/articles/which-opensource-pdf-libraries-are-recently-popular-a-g68q78it) |
||||
|
* [Engincan Veske](https://abp.io/community/members/EngincanV): |
||||
|
* [Stop Spam and Toxic Users in Your App with AI](https://abp.io/community/articles/stop-spam-and-toxic-users-in-your-app-with-ai-3i0xxh0y) |
||||
|
* [Liming Ma](https://abp.io/community/members/maliming): |
||||
|
* [How AI Is Changing Developers](https://abp.io/community/articles/how-ai-is-changing-developers-e8y4a85f) |
||||
|
* [Tarık Özdemir](https://abp.io/community/members/mtozdemir): |
||||
|
* [JetBrains State of Developer Ecosystem Report 2025 — Key Insights](https://abp.io/community/articles/jetbrains-state-of-developer-ecosystem-report-2025-key-z0638q5e) |
||||
|
* [Adnan Ali](https://abp.io/community/members/adnanaldaim): |
||||
|
* [Integrating AI into ABP.IO Applications: The Complete Guide to Volo.Abp.AI and AI Management Module](https://abp.io/community/articles/integrating-ai-into-abp.io-applications-the-complete-guide-jc9fbjq0) |
||||
|
|
||||
|
Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. |
||||
|
|
||||
|
## About the Next Version |
||||
|
|
||||
|
The next feature version will be 10.2. You can follow the [release planning here](https://github.com/abpframework/abp/milestones). Please [submit an issue](https://github.com/abpframework/abp/issues/new) if you have any problems with this version. |
||||
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,377 @@ |
|||||
|
# Building a Multi-Agent AI System with A2A, MCP, and ADK in .NET |
||||
|
|
||||
|
> How we combined three open AI protocols — Google's A2A & ADK with Anthropic's MCP — to build a production-ready Multi-Agent Research Assistant using .NET 10. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Introduction |
||||
|
|
||||
|
The AI space is constantly changing and improving. Once again, we've moved past the single LLM calls and into the future of **Multi-Agent Systems**, in which expert AI agents act in unison as a collaborative team. |
||||
|
|
||||
|
But here is the problem: **How do you make agents communicate with each other? How do you equip agents with tools? How do you control them?** |
||||
|
|
||||
|
Three open protocols have emerged for answering these questions: |
||||
|
|
||||
|
- **MCP (Model Context Protocol)** by Anthropic — The "USB-C for AI" |
||||
|
- **A2A (Agent-to-Agent Protocol)** by Google — The "phone line between agents" |
||||
|
- **ADK (Agent Development Kit)** by Google — The "organizational chart for agents" |
||||
|
|
||||
|
In this article, I will briefly describe each protocol, highlight the benefits of the combination, and walk you through our own project: a **Multi-Agent Research Assistant** developed via ABP Framework. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## The Problem: Why Single-Agent Isn't Enough |
||||
|
|
||||
|
Imagine you ask an AI: *"Research the latest AI agent frameworks and give me a comprehensive analysis report."* |
||||
|
|
||||
|
A single LLM call would: |
||||
|
- Hallucinate search results (can't actually browse the web) |
||||
|
- Produce a shallow analysis (no structured research pipeline) |
||||
|
- Lose context between steps (no state management) |
||||
|
- Can't save results anywhere (no tool access) |
||||
|
|
||||
|
What you actually need is a **team of specialists**: |
||||
|
|
||||
|
1. A **Researcher** who searches the web and gathers raw data |
||||
|
2. An **Analyst** who processes that data into a structured report |
||||
|
3. **Tools** that let agents interact with the real world (web, database, filesystem) |
||||
|
4. An **Orchestrator** that coordinates everything |
||||
|
|
||||
|
This is exactly what we built. |
||||
|
|
||||
|
 |
||||
|
--- |
||||
|
|
||||
|
## Protocol #1: MCP — Giving Agents Superpowers |
||||
|
|
||||
|
### What is MCP? |
||||
|
|
||||
|
**MCP (Model Context Protocol)**: Anthropic's standardized protocol allows AI models to be connected to all external tools and data sources. MCP can be thought of as **the USB-C of AI** – one port compatible with everything. |
||||
|
|
||||
|
Earlier, before MCP, if you wanted your LLM to do things such as search the web, query a database, and store files, you would need to write your own integration code for each capability. MCP lets you define your tools one time, and any agent that is MCP-compatible can make use of them. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### How MCP Works |
||||
|
|
||||
|
MCP follows a simple **Client-Server architecture**: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
The flow is straightforward: |
||||
|
|
||||
|
1. **Discovery**: The agent asks "What tools do you have?" (`tools/list`) |
||||
|
2. **Invocation**: The agent calls a specific tool (`tools/call`) |
||||
|
3. **Result**: The tool returns data back to the agent |
||||
|
|
||||
|
### MCP in Our Project |
||||
|
|
||||
|
We built three MCP tool servers: |
||||
|
|
||||
|
| MCP Tool | Purpose | Used By | |
||||
|
|----------|---------|---------| |
||||
|
| `web_search` | Searches the web via Tavily API | Researcher Agent | |
||||
|
| `fetch_url_content` | Fetches content from a URL | Researcher Agent | |
||||
|
| `save_research_to_file` | Saves reports to the filesystem | Analysis Agent | |
||||
|
| `save_research_to_database` | Persists results in SQL Server | Analysis Agent | |
||||
|
| `search_past_research` | Queries historical research | Analysis Agent | |
||||
|
|
||||
|
The beauty of MCP is that you do not need to know how these tools are implemented inside the tool. You simply need to call them by their names as given in the description. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Protocol #2: A2A — Making Agents Talk to Each Other |
||||
|
|
||||
|
### What is A2A? |
||||
|
|
||||
|
**A2A (Agent to Agent)**, formerly proposed by Google and now presented under the Linux Foundation, describes a protocol allowing **one AI agent to discover another and trade tasks**. MCP fits as helping agents acquire tools; A2A helps them acquire the ability to speak. |
||||
|
|
||||
|
Think of it this way: |
||||
|
- **MCP** = "What can this agent *do*?" (capabilities) |
||||
|
- **A2A** = "How do agents *talk*?" (communication) |
||||
|
|
||||
|
### The Agent Card: Your Agent's Business Card |
||||
|
|
||||
|
Every A2A-compatible agent publishes an **Agent Card** — a JSON document that describes who it is and what it can do. It's like a business card for AI agents: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "Researcher Agent", |
||||
|
"description": "Searches the web to collect comprehensive research data", |
||||
|
"url": "https://localhost:44331/a2a/researcher", |
||||
|
"version": "1.0.0", |
||||
|
"capabilities": { |
||||
|
"streaming": false, |
||||
|
"pushNotifications": false |
||||
|
}, |
||||
|
"skills": [ |
||||
|
{ |
||||
|
"id": "web-research", |
||||
|
"name": "Web Research", |
||||
|
"description": "Searches the web on a given topic and collects raw data", |
||||
|
"tags": ["research", "web-search", "data-collection"] |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Other agents can discover this card at `/.well-known/agent.json` and immediately know: |
||||
|
- What this agent does |
||||
|
- Where to reach it |
||||
|
- What skills it has |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### How A2A Task Exchange Works |
||||
|
|
||||
|
Once an agent discovers another agent, it can send tasks: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
The key concepts: |
||||
|
|
||||
|
- **Task**: A unit of work sent between agents (like an email with instructions) |
||||
|
- **Artifact**: The output produced by an agent (like an attachment in the reply) |
||||
|
- **Task State**: `Submitted → Working → Completed/Failed` |
||||
|
|
||||
|
### A2A in Our Project |
||||
|
|
||||
|
Agent communication in our system uses A2A: |
||||
|
|
||||
|
- The **Orchestrator** finds all agents through the Agent Cards |
||||
|
- It sends a research task to the **Researcher Agent** |
||||
|
- The Researcher’s output (artifacts) is used as input by **Analysis Agent** - The Analysis Agent creates the final structured report |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Protocol #3: ADK — Organizing Your Agent Team |
||||
|
|
||||
|
### What is ADK? |
||||
|
|
||||
|
**ADK (Agent Development Kit)**, created by Google, provides patterns for **organizing and orchestrating multiple agents**. It answers the question: "How do you build a team of agents that work together efficiently?" |
||||
|
|
||||
|
ADK gives you: |
||||
|
- **BaseAgent**: A foundation every agent inherits from |
||||
|
- **SequentialAgent**: Runs agents one after another (pipeline) |
||||
|
- **ParallelAgent**: Runs agents simultaneously |
||||
|
- **AgentContext**: Shared state that flows through the pipeline |
||||
|
- **AgentEvent**: Control flow signals (escalate, transfer, state updates) |
||||
|
|
||||
|
> **Note**: ADK's official SDK is Python-only. We ported the core patterns to .NET for our project. |
||||
|
|
||||
|
### The Pipeline Pattern |
||||
|
|
||||
|
The most powerful ADK pattern is the **Sequential Pipeline**. Think of it as an assembly line in a factory: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
Each agent: |
||||
|
1. Receives the shared **AgentContext** (with state from previous agents) |
||||
|
2. Does its work |
||||
|
3. Updates the state |
||||
|
4. Passes it to the next agent |
||||
|
|
||||
|
### AgentContext: The Shared Memory |
||||
|
|
||||
|
`AgentContext` is like a shared whiteboard that all agents can read from and write to: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
This pattern eliminates the need for complex inter-agent messaging — agents simply read and write to a shared context. |
||||
|
|
||||
|
### ADK Orchestration Patterns |
||||
|
|
||||
|
ADK supports multiple orchestration patterns: |
||||
|
|
||||
|
| Pattern | Description | Use Case | |
||||
|
|---------|-------------|----------| |
||||
|
| **Sequential** | A → B → C | Research → Analysis pipeline | |
||||
|
| **Parallel** | A, B, C simultaneously | Multiple searches at once | |
||||
|
| **Fan-Out/Fan-In** | Split → Process → Merge | Distributed research | |
||||
|
| **Conditional Routing** | If/else agent selection | Route by query type | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## How the Three Protocols Work Together |
||||
|
|
||||
|
Here's the key insight: **MCP, A2A, and ADK are not competitors — they're complementary layers of a complete agent system.** |
||||
|
|
||||
|
 |
||||
|
|
||||
|
Each protocol handles a different concern: |
||||
|
|
||||
|
| Layer | Protocol | Question It Answers | |
||||
|
|-------|----------|-------------------| |
||||
|
| **Top** | ADK | "How are agents organized?" | |
||||
|
| **Middle** | A2A | "How do agents communicate?" | |
||||
|
| **Bottom** | MCP | "What tools can agents use?" | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Our Project: Multi-Agent Research Assistant |
||||
|
|
||||
|
### Built With |
||||
|
|
||||
|
- **.NET 10.0** — Latest runtime |
||||
|
- **ABP Framework 10.0.2** — Enterprise .NET application framework |
||||
|
- **Semantic Kernel 1.70.0** — Microsoft's AI orchestration SDK |
||||
|
- **Azure OpenAI (GPT)** — LLM backbone |
||||
|
- **Tavily Search API** — Real-time web search |
||||
|
- **SQL Server** — Research persistence |
||||
|
- **MCP SDK** (`ModelContextProtocol` 0.8.0-preview.1) |
||||
|
- **A2A SDK** (`A2A` 0.3.3-preview) |
||||
|
|
||||
|
|
||||
|
### How It Works (Step by Step) |
||||
|
|
||||
|
**Step 1: User Submits a Query** |
||||
|
|
||||
|
For example, the user specifies a field of research in the dashboard: *“Compare the latest AI agent frameworks: LangChain, Semantic Kernel, and AutoGen”*, and then specifies execution mode as ADK-Sequential or A2A. |
||||
|
|
||||
|
**Step 2: Orchestrator Activates** |
||||
|
|
||||
|
The `ResearchOrchestrator` receives the query and constructs the `AgentContext`. In ADK mode, it constructs a `SequentialAgent` with two sub-agents; in A2A mode, it uses the `A2AServer` to send the tasks. |
||||
|
|
||||
|
**Step 3: Researcher Agent Goes to Work** |
||||
|
|
||||
|
The Researcher Agent: |
||||
|
- Receives the query from the context |
||||
|
- Uses GPT to formulate optimal search queries |
||||
|
- Calls the `web_search` MCP tool (powered by Tavily API) |
||||
|
- Collects and synthesizes raw research data |
||||
|
- Stores results in the shared `AgentContext` |
||||
|
|
||||
|
**Step 4: Analysis Agent Takes Over** |
||||
|
|
||||
|
The Analysis Agent: |
||||
|
- Reads the Researcher's raw data from `AgentContext` |
||||
|
- Uses GPT to perform deep analysis |
||||
|
- Generates a structured Markdown report with sections: |
||||
|
- Executive Summary |
||||
|
- Key Findings |
||||
|
- Detailed Analysis |
||||
|
- Comparative Assessment |
||||
|
- Conclusion and Recommendations |
||||
|
- Calls MCP tools to save the report to both filesystem and database |
||||
|
|
||||
|
**Step 5: Results Returned** |
||||
|
|
||||
|
The orchestrator collects all results and returns them to the user via the REST API. The dashboard displays the research report, analysis report, agent event timeline, and raw data. |
||||
|
|
||||
|
|
||||
|
### Two Execution Modes |
||||
|
|
||||
|
Our system supports two execution modes, demonstrating both ADK and A2A approaches: |
||||
|
|
||||
|
#### Mode 1: ADK Sequential Pipeline |
||||
|
|
||||
|
Agents are organized as a `SequentialAgent`. State flows automatically through the pipeline via `AgentContext`. This is an in-process approach — fast and simple. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
#### Mode 2: A2A Protocol-Based |
||||
|
|
||||
|
Agents communicate via the A2A protocol. The Orchestrator sends `AgentTask` objects to each agent through the `A2AServer`. Each agent has its own `AgentCard` for discovery. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### The Dashboard |
||||
|
|
||||
|
The UI provides a complete research experience: |
||||
|
|
||||
|
- **Hero Section** with system description and protocol badges |
||||
|
- **Architecture Cards** showing all four components (Researcher, Analyst, MCP Tools, Orchestrator) |
||||
|
- **Research Form** with query input and mode selection |
||||
|
- **Live Pipeline Status** tracking each stage of execution |
||||
|
- **Tabbed Results** view: Research Report, Analysis Report, Raw Data, Agent Events |
||||
|
- **Research History** table with past queries and their results |
||||
|
|
||||
|
|
||||
|
 |
||||
|
|
||||
|
 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Why ABP Framework? |
||||
|
|
||||
|
We chose ABP Framework as our .NET application foundation. Here's why it was a natural fit: |
||||
|
|
||||
|
| ABP Feature | How We Used It | |
||||
|
|-------------|---------------| |
||||
|
| **Auto API Controllers** | `ResearchAppService` automatically becomes REST API endpoints | |
||||
|
| **Dependency Injection** | Clean registration of agents, tools, orchestrator, Semantic Kernel | |
||||
|
| **Repository Pattern** | `IRepository<ResearchRecord>` for database operations in MCP tools | |
||||
|
| **Module System** | All agent ecosystem config encapsulated in `AgentEcosystemModule` | |
||||
|
| **Entity Framework Core** | Research record persistence with code-first migrations | |
||||
|
| **Built-in Auth** | OpenIddict integration for securing agent endpoints | |
||||
|
| **Health Checks** | Monitoring agent ecosystem health | |
||||
|
|
||||
|
ABP's single layer template provided us the best .NET groundwork, which had all the enterprise features without any unnecessary complexity for a focused AI project. Of course, the agent architecture (MCP, A2A, ADK) is actually framework-agnostic and can be implemented with any .NET application. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Key Takeaways |
||||
|
|
||||
|
### 1. Protocols Are Complementary, Not Competing |
||||
|
|
||||
|
MCP, A2A, and ADK solve different problems. Using them together creates a complete agent system: |
||||
|
- **MCP**: Standardize tool access |
||||
|
- **A2A**: Standardize inter-agent communication |
||||
|
- **ADK**: Standardize agent orchestration |
||||
|
|
||||
|
### 2. Start Simple, Scale Later |
||||
|
|
||||
|
Our approach runs all of that in a single process, which is in-process A2A. Using A2A allowed us to design the code so that each agent can be extracted into its own microservice later on without affecting the code logic. |
||||
|
|
||||
|
### 3. Shared State > Message Passing (For Simple Cases) |
||||
|
|
||||
|
ADK's `AgentContext` with shared state is simpler and faster than A2A message passing for in-process scenarios. Use A2A when agents need to run as separate services. |
||||
|
|
||||
|
### 4. MCP is the Real Game-Changer |
||||
|
|
||||
|
The ability to define tools once and have any agent use them — with automatic discovery and structured invocations — eliminates enormous amounts of boilerplate code. |
||||
|
|
||||
|
### 5. LLM Abstraction is Critical |
||||
|
|
||||
|
Using Semantic Kernel's `IChatCompletionService` lets you swap between Azure OpenAI, OpenAI, Ollama, or any provider without touching agent code. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## What's Next? |
||||
|
|
||||
|
This project demonstrates the foundation of a multi-agent system. Future enhancements could include: |
||||
|
|
||||
|
- **Streaming responses** — Real-time updates as agents work (A2A supports this) |
||||
|
- **More specialized agents** — Code analysis, translation, fact-checking agents |
||||
|
- **Distributed deployment** — Each agent as a separate microservice with HTTP-based A2A |
||||
|
- **Agent marketplace** — Discover and integrate third-party agents via A2A Agent Cards |
||||
|
- **Human-in-the-loop** — Using A2A's `InputRequired` state for human approval steps |
||||
|
- **RAG integration** — MCP tools for vector database search |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Resources |
||||
|
|
||||
|
| Resource | Link | |
||||
|
|----------|------| |
||||
|
| **MCP Specification** | [modelcontextprotocol.io](https://modelcontextprotocol.io) | |
||||
|
| **A2A Specification** | [google.github.io/A2A](https://google.github.io/A2A) | |
||||
|
| **ADK Documentation** | [google.github.io/adk-docs](https://google.github.io/adk-docs) | |
||||
|
| **ABP Framework** | [abp.io](https://abp.io) | |
||||
|
| **Semantic Kernel** | [github.com/microsoft/semantic-kernel](https://github.com/microsoft/semantic-kernel) | |
||||
|
| **MCP .NET SDK** | [NuGet: ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) | |
||||
|
| **A2A .NET SDK** | [NuGet: A2A](https://www.nuget.org/packages/A2A) | |
||||
|
| **Our Source Code** | [GitHub Repository](https://github.com/fahrigedik/agent-ecosystem-in-abp) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Conclusion |
||||
|
|
||||
|
Developing a multi-agent AI system is no longer a futuristic dream; it’s something that can actually be achieved today by using open protocols and available frameworks. In this manner, by using **MCP** for access to tools, **A2A** for communicating between agents, and **ADK** for orchestration, we have actually built a Research Assistant. |
||||
|
|
||||
|
ABP Framework and .NET turned out to be an excellent choice, delivering us the infrastructure we needed to implement DI, repositories, auto APIs, and modules, allowing us to work completely on the AI agent architecture. |
||||
|
|
||||
|
The era of single LLM calls is ending, and the era of agent ecosystems begins now. |
||||
|
|
||||
|
--- |
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 358 KiB |
@ -0,0 +1,728 @@ |
|||||
|
# Implementing Multiple Global Query Filters with Entity Framework Core |
||||
|
|
||||
|
Global query filters are one of Entity Framework Core's most powerful features for automatically filtering data based on certain conditions. They allow you to define filter criteria at the entity level that are automatically applied to all LINQ queries, making it impossible for developers to accidentally forget to include important filtering logic. In this article, we'll explore how to implement multiple global query filters in ABP Framework, covering built-in filters, custom filters, and performance optimization techniques. |
||||
|
|
||||
|
By the end of this guide, you'll understand how ABP Framework's data filtering system works, how to create custom global query filters for your specific business requirements, how to combine multiple filters effectively, and how to optimize filter performance using user-defined functions. |
||||
|
|
||||
|
## Understanding Global Query Filters in EF Core |
||||
|
|
||||
|
Global query filters were introduced in EF Core 2.0 and allow you to automatically append LINQ predicates to queries generated for an entity type. This is particularly useful for scenarios like multi-tenancy, soft delete, data isolation, and row-level security. |
||||
|
|
||||
|
In traditional applications, developers must remember to add filter conditions manually to every query: |
||||
|
|
||||
|
```csharp |
||||
|
// Manual filtering - error-prone and tedious |
||||
|
var activeBooks = await _bookRepository |
||||
|
.GetListAsync(b => b.IsDeleted == false && b.TenantId == currentTenantId); |
||||
|
``` |
||||
|
|
||||
|
With global query filters, this logic is applied automatically: |
||||
|
|
||||
|
```csharp |
||||
|
// Filter is applied automatically - no manual filtering needed |
||||
|
var activeBooks = await _bookRepository.GetListAsync(); |
||||
|
``` |
||||
|
|
||||
|
ABP Framework provides a sophisticated data filtering system built on top of EF Core's global query filters, with built-in support for soft delete, multi-tenancy, and the ability to easily create custom filters. |
||||
|
|
||||
|
### Important: Plain EF Core vs ABP Composition |
||||
|
|
||||
|
In plain EF Core, calling `HasQueryFilter` multiple times for the same entity does **not** create multiple active filters. The last call replaces the previous one (unless you use newer named-filter APIs in recent EF Core versions). |
||||
|
|
||||
|
ABP provides `HasAbpQueryFilter` to compose query filters safely. This method combines your custom filter with ABP's built-in filters (such as `ISoftDelete` and `IMultiTenant`) and with other `HasAbpQueryFilter` calls. |
||||
|
|
||||
|
## ABP Framework's Data Filtering System |
||||
|
|
||||
|
ABP's data filtering system is defined in the `Volo.Abp.Data` namespace and provides a consistent way to manage filters across your application. The core interface is `IDataFilter<TFilter>`, which allows you to enable or disable filters programmatically. |
||||
|
|
||||
|
### Built-in Filters |
||||
|
|
||||
|
ABP Framework comes with several built-in filters: |
||||
|
|
||||
|
1. **ISoftDelete**: Automatically filters out soft-deleted entities |
||||
|
2. **IMultiTenant**: Automatically filters entities by current tenant (for SaaS applications) |
||||
|
3. **IIsActive**: Filters entities based on active status |
||||
|
|
||||
|
Let's look at how these are implemented in the ABP framework: |
||||
|
|
||||
|
The `ISoftDelete` interface is straightforward: |
||||
|
|
||||
|
```csharp |
||||
|
namespace Volo.Abp; |
||||
|
|
||||
|
public interface ISoftDelete |
||||
|
{ |
||||
|
bool IsDeleted { get; } |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Any entity implementing this interface will automatically have deleted records filtered out of queries. |
||||
|
|
||||
|
### Enabling and Disabling Filters |
||||
|
|
||||
|
ABP provides the `IDataFilter<TFilter>` service to control filter behavior at runtime: |
||||
|
|
||||
|
```csharp |
||||
|
public class BookAppService : ApplicationService |
||||
|
{ |
||||
|
private readonly IDataFilter<ISoftDelete> _softDeleteFilter; |
||||
|
private readonly IRepository<Book, Guid> _bookRepository; |
||||
|
|
||||
|
public BookAppService( |
||||
|
IDataFilter<ISoftDelete> softDeleteFilter, |
||||
|
IRepository<Book, Guid> bookRepository) |
||||
|
{ |
||||
|
_softDeleteFilter = softDeleteFilter; |
||||
|
_bookRepository = bookRepository; |
||||
|
} |
||||
|
|
||||
|
public async Task<List<Book>> GetAllBooksIncludingDeletedAsync() |
||||
|
{ |
||||
|
// Temporarily disable the soft delete filter |
||||
|
using (_softDeleteFilter.Disable()) |
||||
|
{ |
||||
|
return await _bookRepository.GetListAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<List<Book>> GetActiveBooksAsync() |
||||
|
{ |
||||
|
// Filter is enabled by default - soft-deleted items are excluded |
||||
|
return await _bookRepository.GetListAsync(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
You can also check if a filter is enabled and enable/disable it programmatically: |
||||
|
|
||||
|
```csharp |
||||
|
public async Task ProcessBooksAsync() |
||||
|
{ |
||||
|
// Check if filter is enabled |
||||
|
if (_softDeleteFilter.IsEnabled) |
||||
|
{ |
||||
|
// Enable or disable explicitly |
||||
|
_softDeleteFilter.Enable(); |
||||
|
// or |
||||
|
_softDeleteFilter.Disable(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Creating Custom Global Query Filters |
||||
|
|
||||
|
Now let's create custom global query filters for a real-world scenario. Imagine we have a library management system where we need to filter books based on: |
||||
|
|
||||
|
1. **Publication Status**: Only show published books in public areas |
||||
|
2. **User's Department**: Users can only see books from their department |
||||
|
3. **Approval Status**: Only show approved content |
||||
|
|
||||
|
### Step 1: Define Filter Interfaces |
||||
|
|
||||
|
First, create the filter interfaces. You can define them in the same file as your entity or in separate files: |
||||
|
|
||||
|
```csharp |
||||
|
// Can be placed in the same file as Book entity or in separate files |
||||
|
namespace Library; |
||||
|
|
||||
|
public interface IPublishable |
||||
|
{ |
||||
|
bool IsPublished { get; } |
||||
|
DateTime PublishDate { get; set; } |
||||
|
} |
||||
|
|
||||
|
public interface IDepartmentRestricted |
||||
|
{ |
||||
|
Guid DepartmentId { get; } |
||||
|
} |
||||
|
|
||||
|
public interface IApproveable |
||||
|
{ |
||||
|
bool IsApproved { get; } |
||||
|
} |
||||
|
|
||||
|
public interface IPublishedFilter |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public interface IApprovedFilter |
||||
|
{ |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`IPublishable` / `IApproveable` are implemented by entities and define entity properties. |
||||
|
`IPublishedFilter` / `IApprovedFilter` are filter-state interfaces used with `IDataFilter` so you can enable/disable those filters at runtime. |
||||
|
|
||||
|
### Step 2: Add Filter Expressions to DbContext |
||||
|
|
||||
|
Now let's add the filter expressions to your existing DbContext. First, here's how to use `HasAbpQueryFilter` to create **always-on** filters (they cannot be toggled at runtime): |
||||
|
|
||||
|
```csharp |
||||
|
// MyProjectDbContext.cs |
||||
|
using Microsoft.EntityFrameworkCore; |
||||
|
using Volo.Abp.EntityFrameworkCore; |
||||
|
using Volo.Abp.GlobalFeatures; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
using Volo.Abp.Authorization; |
||||
|
using Volo.Abp.Data; |
||||
|
using Volo.Abp.EntityFrameworkCore.Modeling; |
||||
|
|
||||
|
namespace Library; |
||||
|
|
||||
|
public class LibraryDbContext : AbpDbContext<LibraryDbContext> |
||||
|
{ |
||||
|
public DbSet<Book> Books { get; set; } |
||||
|
public DbSet<Department> Departments { get; set; } |
||||
|
public DbSet<Author> Authors { get; set; } |
||||
|
|
||||
|
public LibraryDbContext(DbContextOptions<LibraryDbContext> options) |
||||
|
: base(options) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
builder.Entity<Book>(b => |
||||
|
{ |
||||
|
b.ToTable("Books"); |
||||
|
b.ConfigureByConvention(); |
||||
|
|
||||
|
// HasAbpQueryFilter creates ALWAYS-ACTIVE filters |
||||
|
// These cannot be toggled at runtime via IDataFilter |
||||
|
b.HasAbpQueryFilter(book => |
||||
|
book.IsPublished && |
||||
|
book.PublishDate <= DateTime.UtcNow); |
||||
|
|
||||
|
b.HasAbpQueryFilter(book => book.IsApproved); |
||||
|
}); |
||||
|
|
||||
|
builder.Entity<Department>(b => |
||||
|
{ |
||||
|
b.ToTable("Departments"); |
||||
|
b.ConfigureByConvention(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Note:** Using `HasAbpQueryFilter` alone creates filters that are always active and cannot be toggled at runtime. This approach is simpler but less flexible. For toggleable filters, see Step 3 below. |
||||
|
|
||||
|
### Step 3: Make Filters Toggleable (Optional) |
||||
|
|
||||
|
If you need filters that can be enabled/disabled at runtime via `IDataFilter<T>`, override `ShouldFilterEntity` and `CreateFilterExpression` instead of (or in addition to) `HasAbpQueryFilter`: |
||||
|
|
||||
|
```csharp |
||||
|
// MyProjectDbContext.cs |
||||
|
using System; |
||||
|
using System.Linq.Expressions; |
||||
|
using Microsoft.EntityFrameworkCore; |
||||
|
using Microsoft.EntityFrameworkCore.Metadata; |
||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
using Volo.Abp.EntityFrameworkCore; |
||||
|
|
||||
|
namespace Library; |
||||
|
|
||||
|
public class LibraryDbContext : AbpDbContext<LibraryDbContext> |
||||
|
{ |
||||
|
protected bool IsPublishedFilterEnabled => DataFilter?.IsEnabled<IPublishedFilter>() ?? false; |
||||
|
protected bool IsApprovedFilterEnabled => DataFilter?.IsEnabled<IApprovedFilter>() ?? false; |
||||
|
|
||||
|
protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType) |
||||
|
{ |
||||
|
if (typeof(IPublishable).IsAssignableFrom(typeof(TEntity))) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (typeof(IApproveable).IsAssignableFrom(typeof(TEntity))) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
return base.ShouldFilterEntity<TEntity>(entityType); |
||||
|
} |
||||
|
|
||||
|
protected override Expression<Func<TEntity, bool>>? CreateFilterExpression<TEntity>( |
||||
|
ModelBuilder modelBuilder, |
||||
|
EntityTypeBuilder<TEntity> entityTypeBuilder) |
||||
|
where TEntity : class |
||||
|
{ |
||||
|
var expression = base.CreateFilterExpression<TEntity>(modelBuilder, entityTypeBuilder); |
||||
|
|
||||
|
if (typeof(IPublishable).IsAssignableFrom(typeof(TEntity))) |
||||
|
{ |
||||
|
Expression<Func<TEntity, bool>> publishFilter = e => |
||||
|
!IsPublishedFilterEnabled || |
||||
|
( |
||||
|
EF.Property<bool>(e, nameof(IPublishable.IsPublished)) && |
||||
|
EF.Property<DateTime>(e, nameof(IPublishable.PublishDate)) <= DateTime.UtcNow |
||||
|
); |
||||
|
|
||||
|
expression = expression == null |
||||
|
? publishFilter |
||||
|
: QueryFilterExpressionHelper.CombineExpressions(expression, publishFilter); |
||||
|
} |
||||
|
|
||||
|
if (typeof(IApproveable).IsAssignableFrom(typeof(TEntity))) |
||||
|
{ |
||||
|
Expression<Func<TEntity, bool>> approvalFilter = e => |
||||
|
!IsApprovedFilterEnabled || EF.Property<bool>(e, nameof(IApproveable.IsApproved)); |
||||
|
|
||||
|
expression = expression == null |
||||
|
? approvalFilter |
||||
|
: QueryFilterExpressionHelper.CombineExpressions(expression, approvalFilter); |
||||
|
} |
||||
|
|
||||
|
return expression; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
This mapping step is what connects `IDataFilter<IPublishedFilter>` and `IDataFilter<IApprovedFilter>` to entity-level predicates. Without this step, `HasAbpQueryFilter` expressions remain always active. |
||||
|
|
||||
|
> **Important:** Note that we use `DateTime` (not `DateTime?`) in the filter expression to match the entity property type. Adjust accordingly if your entity uses nullable `DateTime?`. |
||||
|
|
||||
|
### Step 4: Disable Custom Filters with IDataFilter |
||||
|
|
||||
|
Once custom filters are mapped to the ABP data-filter pipeline, you can disable them just like built-in filters: |
||||
|
|
||||
|
```csharp |
||||
|
public class BookAppService : ApplicationService |
||||
|
{ |
||||
|
private readonly IRepository<Book, Guid> _bookRepository; |
||||
|
private readonly IDataFilter<IPublishedFilter> _publishedFilter; |
||||
|
private readonly IDataFilter<IApprovedFilter> _approvedFilter; |
||||
|
|
||||
|
public BookAppService( |
||||
|
IRepository<Book, Guid> bookRepository, |
||||
|
IDataFilter<IPublishedFilter> publishedFilter, |
||||
|
IDataFilter<IApprovedFilter> approvedFilter) |
||||
|
{ |
||||
|
_bookRepository = bookRepository; |
||||
|
_publishedFilter = publishedFilter; |
||||
|
_approvedFilter = approvedFilter; |
||||
|
} |
||||
|
|
||||
|
public async Task<List<Book>> GetIncludingUnpublishedAndUnapprovedAsync() |
||||
|
{ |
||||
|
using (_publishedFilter.Disable()) |
||||
|
using (_approvedFilter.Disable()) |
||||
|
{ |
||||
|
return await _bookRepository.GetListAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Advanced: Multiple Filters with User-Defined Functions |
||||
|
|
||||
|
Starting from ABP v8.3, you can use user-defined function (UDF) mapping for better performance. This approach generates more efficient SQL and allows EF Core to create better execution plans. |
||||
|
|
||||
|
### Step 1: Enable UDF Mapping |
||||
|
|
||||
|
First, configure your module to use UDF mapping: |
||||
|
|
||||
|
```csharp |
||||
|
// MyProjectModule.cs |
||||
|
using Volo.Abp.EntityFrameworkCore; |
||||
|
using Volo.Abp.EntityFrameworkCore.GlobalFilters; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
|
||||
|
namespace Library; |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpEntityFrameworkCoreModule), |
||||
|
typeof(AbpDddDomainModule) |
||||
|
)] |
||||
|
public class LibraryModule : AbpModule |
||||
|
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
Configure<AbpEfCoreGlobalFilterOptions>(options => |
||||
|
{ |
||||
|
options.UseDbFunction = true; // Enable UDF mapping |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Step 2: Define DbFunctions |
||||
|
|
||||
|
Create static methods that EF Core will map to database functions: |
||||
|
|
||||
|
```csharp |
||||
|
// LibraryDbFunctions.cs |
||||
|
using Microsoft.EntityFrameworkCore; |
||||
|
|
||||
|
namespace Library; |
||||
|
|
||||
|
public static class LibraryDbFunctions |
||||
|
{ |
||||
|
public static bool IsPublishedFilter(bool isPublished, DateTime? publishDate) |
||||
|
{ |
||||
|
return isPublished && (publishDate == null || publishDate <= DateTime.UtcNow); |
||||
|
} |
||||
|
|
||||
|
public static bool IsApprovedFilter(bool isApproved) |
||||
|
{ |
||||
|
return isApproved; |
||||
|
} |
||||
|
|
||||
|
public static bool DepartmentFilter(Guid entityDepartmentId, Guid userDepartmentId) |
||||
|
{ |
||||
|
return entityDepartmentId == userDepartmentId; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Step 4: Apply UDF Filters |
||||
|
|
||||
|
Update your DbContext to use the UDF-based filters: |
||||
|
|
||||
|
```csharp |
||||
|
// MyProjectDbContext.cs |
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
// Map CLR methods to SQL scalar functions. |
||||
|
// Create matching SQL functions in a migration. |
||||
|
var isPublishedMethod = typeof(LibraryDbFunctions).GetMethod( |
||||
|
nameof(LibraryDbFunctions.IsPublishedFilter), |
||||
|
new[] { typeof(bool), typeof(DateTime?) })!; |
||||
|
builder.HasDbFunction(isPublishedMethod); |
||||
|
|
||||
|
var isApprovedMethod = typeof(LibraryDbFunctions).GetMethod( |
||||
|
nameof(LibraryDbFunctions.IsApprovedFilter), |
||||
|
new[] { typeof(bool) })!; |
||||
|
builder.HasDbFunction(isApprovedMethod); |
||||
|
|
||||
|
builder.Entity<Book>(b => |
||||
|
{ |
||||
|
b.ToTable("Books"); |
||||
|
b.ConfigureByConvention(); |
||||
|
|
||||
|
// ABP way: define separate filters. HasAbpQueryFilter composes them. |
||||
|
b.HasAbpQueryFilter(book => |
||||
|
LibraryDbFunctions.IsPublishedFilter(book.IsPublished, book.PublishDate)); |
||||
|
|
||||
|
b.HasAbpQueryFilter(book => |
||||
|
LibraryDbFunctions.IsApprovedFilter(book.IsApproved)); |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
This approach generates cleaner SQL and improves query performance, especially in complex scenarios with multiple filters. |
||||
|
|
||||
|
## Working with Complex Filter Combinations |
||||
|
|
||||
|
When combining multiple filters, it's important to understand how they interact. Let's explore some common scenarios. |
||||
|
|
||||
|
### Combining Tenant and Department Filters |
||||
|
|
||||
|
In a multi-tenant application, you might need to combine tenant isolation with department-level access control: |
||||
|
|
||||
|
```csharp |
||||
|
public class BookAppService : ApplicationService |
||||
|
{ |
||||
|
private readonly IRepository<Book, Guid> _bookRepository; |
||||
|
private readonly IDataFilter<IMultiTenant> _tenantFilter; |
||||
|
private readonly ICurrentUser _currentUser; |
||||
|
|
||||
|
public BookAppService( |
||||
|
IRepository<Book, Guid> bookRepository, |
||||
|
IDataFilter<IMultiTenant> tenantFilter, |
||||
|
ICurrentUser currentUser) |
||||
|
{ |
||||
|
_bookRepository = bookRepository; |
||||
|
_tenantFilter = tenantFilter; |
||||
|
_currentUser = currentUser; |
||||
|
} |
||||
|
|
||||
|
public async Task<List<BookDto>> GetMyDepartmentBooksAsync() |
||||
|
{ |
||||
|
var currentUser = _currentUser; |
||||
|
var userDepartmentId = GetUserDepartmentId(currentUser); |
||||
|
|
||||
|
// Get all books without department filter, then filter in memory |
||||
|
// (for scenarios where you need custom filter logic) |
||||
|
using (_tenantFilter.Disable()) // Optional: disable tenant filter if needed |
||||
|
{ |
||||
|
var allBooks = await _bookRepository.GetListAsync(); |
||||
|
|
||||
|
// Apply department filter in memory (custom logic) |
||||
|
var departmentBooks = allBooks |
||||
|
.Where(b => b.DepartmentId == userDepartmentId) |
||||
|
.ToList(); |
||||
|
|
||||
|
return ObjectMapper.Map<List<Book>, List<BookDto>>(departmentBooks); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Guid GetUserDepartmentId(ICurrentUser currentUser) |
||||
|
{ |
||||
|
// Get user's department from claims or database |
||||
|
var departmentClaim = currentUser.FindClaim("DepartmentId"); |
||||
|
return Guid.Parse(departmentClaim.Value); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Filter Priority and Override |
||||
|
|
||||
|
Sometimes you need to override filters in specific scenarios. ABP provides a flexible way to handle this: |
||||
|
|
||||
|
```csharp |
||||
|
public async Task<Book> GetBookForEditingAsync(Guid id) |
||||
|
{ |
||||
|
// Disable soft delete filter to get deleted records for restoration |
||||
|
using (DataFilter.Disable<ISoftDelete>()) |
||||
|
{ |
||||
|
return await _bookRepository.GetAsync(id); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public async Task<Book> GetBookIncludingUnpublishedAsync(Guid id) |
||||
|
{ |
||||
|
// Use GetQueryableAsync to customize the query |
||||
|
var query = await _bookRepository.GetQueryableAsync(); |
||||
|
|
||||
|
// Manually apply or bypass filters |
||||
|
var book = await query |
||||
|
.FirstOrDefaultAsync(b => b.Id == id); |
||||
|
|
||||
|
return book; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Best Practices for Multiple Global Query Filters |
||||
|
|
||||
|
When implementing multiple global query filters, consider these best practices: |
||||
|
|
||||
|
### 1. Keep Filters Simple |
||||
|
|
||||
|
Complex filter expressions can significantly impact query performance. Keep each condition focused on a single concern. In ABP, you can define them separately with `HasAbpQueryFilter`, which composes with ABP's built-in filters: |
||||
|
|
||||
|
```csharp |
||||
|
// Good (ABP): separate, focused filters composed by HasAbpQueryFilter |
||||
|
b.HasAbpQueryFilter(b => b.IsPublished); |
||||
|
b.HasAbpQueryFilter(b => b.IsApproved); |
||||
|
b.HasAbpQueryFilter(b => b.DepartmentId == userDeptId); |
||||
|
|
||||
|
// Avoid: calling HasQueryFilter multiple times for the same entity |
||||
|
// in plain EF Core (the last call replaces the previous one) |
||||
|
b.HasQueryFilter(b => b.IsPublished); |
||||
|
b.HasQueryFilter(b => b.IsApproved); |
||||
|
``` |
||||
|
|
||||
|
### 2. Use Indexing |
||||
|
|
||||
|
Ensure your database has appropriate indexes for filtered columns: |
||||
|
|
||||
|
```csharp |
||||
|
builder.Entity<Book>(b => |
||||
|
{ |
||||
|
b.HasIndex(b => b.IsPublished); |
||||
|
b.HasIndex(b => b.IsApproved); |
||||
|
b.HasIndex(b => b.DepartmentId); |
||||
|
b.HasIndex(b => new { b.IsPublished, b.PublishDate }); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
### 3. Consider Performance Impact |
||||
|
|
||||
|
Use UDF mapping for better performance with complex filters. Profile your queries and analyze execution plans. |
||||
|
|
||||
|
### 4. Document Filter Behavior |
||||
|
|
||||
|
Clearly document which filters are applied to each entity to help developers understand the behavior: |
||||
|
|
||||
|
```csharp |
||||
|
/// <summary> |
||||
|
/// Book entity with the following global query filters: |
||||
|
/// - ISoftDelete: Automatically excludes soft-deleted books |
||||
|
/// - IMultiTenant: Automatically filters by current tenant |
||||
|
/// - IPublishable: Excludes unpublished books (based on IsPublished and PublishDate) |
||||
|
/// - IApproveable: Excludes unapproved books (based on IsApproved) |
||||
|
/// </summary> |
||||
|
/// <remarks> |
||||
|
/// Filter interfaces (IPublishable, IApproveable, IPublishedFilter, IApprovedFilter) |
||||
|
/// are defined in Step 1: Define Filter Interfaces |
||||
|
/// </remarks> |
||||
|
public class Book : AuditedAggregateRoot<Guid>, ISoftDelete, IMultiTenant, IPublishable, IApproveable |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
public BookType Type { get; set; } |
||||
|
|
||||
|
public DateTime PublishDate { get; set; } |
||||
|
|
||||
|
public float Price { get; set; } |
||||
|
|
||||
|
public bool IsPublished { get; set; } |
||||
|
|
||||
|
public bool IsApproved { get; set; } |
||||
|
|
||||
|
public Guid? TenantId { get; set; } |
||||
|
|
||||
|
public bool IsDeleted { get; set; } |
||||
|
|
||||
|
public Guid DepartmentId { get; set; } |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Testing Global Query Filters |
||||
|
|
||||
|
Testing with global query filters can be challenging. Here's how to do it effectively: |
||||
|
|
||||
|
### Unit Testing Filters |
||||
|
|
||||
|
```csharp |
||||
|
[Fact] |
||||
|
public void Book_QueryFilter_Should_Filter_Unpublished() |
||||
|
{ |
||||
|
var options = new DbContextOptionsBuilder<BookStoreDbContext>() |
||||
|
.UseInMemoryDatabase(databaseName: "TestDb") |
||||
|
.Options; |
||||
|
|
||||
|
using (var context = new BookStoreDbContext(options)) |
||||
|
{ |
||||
|
context.Books.Add(new Book { Name = "Published Book", IsPublished = true }); |
||||
|
context.Books.Add(new Book { Name = "Unpublished Book", IsPublished = false }); |
||||
|
context.SaveChanges(); |
||||
|
} |
||||
|
|
||||
|
using (var context = new BookStoreDbContext(options)) |
||||
|
{ |
||||
|
// Query with filter enabled (default) |
||||
|
var publishedBooks = context.Books.ToList(); |
||||
|
Assert.Single(publishedBooks); |
||||
|
Assert.Equal("Published Book", publishedBooks[0].Name); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Integration Testing with Filter Control |
||||
|
|
||||
|
```csharp |
||||
|
[Fact] |
||||
|
public async Task Should_Get_Deleted_Book_When_Filter_Disabled() |
||||
|
{ |
||||
|
var dataFilter = GetRequiredService<IDataFilter>(); |
||||
|
|
||||
|
// Arrange |
||||
|
var book = await _bookRepository.InsertAsync( |
||||
|
new Book { Name = "Test Book" }, |
||||
|
autoSave: true |
||||
|
); |
||||
|
|
||||
|
await _bookRepository.DeleteAsync(book); |
||||
|
|
||||
|
// Act - with filter disabled |
||||
|
using (dataFilter.Disable<ISoftDelete>()) |
||||
|
{ |
||||
|
var deletedBook = await _bookRepository |
||||
|
.FirstOrDefaultAsync(b => b.Id == book.Id); |
||||
|
|
||||
|
deletedBook.ShouldNotBeNull(); |
||||
|
deletedBook.IsDeleted.ShouldBeTrue(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Testing Custom Global Query Filters |
||||
|
|
||||
|
Here's a complete example of testing custom toggleable filters: |
||||
|
|
||||
|
```csharp |
||||
|
[Fact] |
||||
|
public async Task Should_Filter_Unpublished_Books_By_Default() |
||||
|
{ |
||||
|
// Default: filters are enabled |
||||
|
var result = await WithUnitOfWorkAsync(async () => |
||||
|
{ |
||||
|
var bookRepository = GetRequiredService<IRepository<Book, Guid>>(); |
||||
|
return await bookRepository.GetListAsync(); |
||||
|
}); |
||||
|
|
||||
|
// Only published and approved books should be returned |
||||
|
result.All(b => b.IsPublished).ShouldBeTrue(); |
||||
|
result.All(b => b.IsApproved).ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_All_Books_When_Filter_Disabled() |
||||
|
{ |
||||
|
var result = await WithUnitOfWorkAsync(async () => |
||||
|
{ |
||||
|
// Disable the published filter to see unpublished books |
||||
|
using (_publishedFilter.Disable()) |
||||
|
{ |
||||
|
var bookRepository = GetRequiredService<IRepository<Book, Guid>>(); |
||||
|
return await bookRepository.GetListAsync(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Should include unpublished books |
||||
|
result.Any(b => b.Name == "Unpublished Book").ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Combine_Filters_Correctly() |
||||
|
{ |
||||
|
// Test combining multiple filter disables |
||||
|
using (_publishedFilter.Disable()) |
||||
|
using (_approvedFilter.Disable()) |
||||
|
{ |
||||
|
var bookRepository = GetRequiredService<IRepository<Book, Guid>>(); |
||||
|
var allBooks = await bookRepository.GetListAsync(); |
||||
|
|
||||
|
// All books should be visible |
||||
|
allBooks.Count.ShouldBe(5); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Tip:** When using ABP's test base, inject `IDataFilter<IPublishedFilter>` and `IDataFilter<IApprovedFilter>` to control filters in your tests. |
||||
|
|
||||
|
## Key Takeaways |
||||
|
|
||||
|
✅ **Global query filters automatically apply filter criteria to all queries**, reducing developer error and ensuring consistent data filtering across your application. |
||||
|
|
||||
|
✅ **ABP Framework provides a sophisticated data filtering system** with built-in support for soft delete (`ISoftDelete`) and multi-tenancy (`IMultiTenant`), plus the ability to create custom filters. |
||||
|
|
||||
|
✅ **Use `IDataFilter<TFilter>` to control filters at runtime**, enabling or disabling filters as needed for specific operations. |
||||
|
|
||||
|
✅ **To make custom filters toggleable, override `ShouldFilterEntity` and `CreateFilterExpression`** in your DbContext. Using only `HasAbpQueryFilter` creates filters that are always active. |
||||
|
|
||||
|
✅ **Combine multiple filters carefully** and consider performance implications, especially with complex filter expressions. |
||||
|
|
||||
|
✅ **Leverage user-defined function (UDF) mapping** for better SQL generation and query performance, available since ABP v8.3. |
||||
|
|
||||
|
✅ **Always test filter behavior** to ensure filters work as expected in different scenarios, including edge cases. |
||||
|
|
||||
|
## Conclusion |
||||
|
|
||||
|
Global query filters are essential for building secure, well-isolated applications. ABP Framework's data filtering system provides a robust foundation that builds on EF Core's capabilities while adding convenient features like runtime filter control and UDF mapping optimization. |
||||
|
|
||||
|
By implementing multiple global query filters strategically, you can ensure data isolation, simplify your query logic, and reduce the risk of accidentally exposing unauthorized data. Remember to keep filters simple, add appropriate database indexes, and test thoroughly to maintain optimal performance. |
||||
|
|
||||
|
Start implementing global query filters in your ABP applications today to leverage automatic data filtering across all your repositories and queries. |
||||
|
|
||||
|
### See Also |
||||
|
|
||||
|
- [ABP Data Filtering Documentation](https://abp.io/docs/latest/framework/fundamentals/data-filtering) |
||||
|
- [EF Core Global Query Filters](https://learn.microsoft.com/en-us/ef/core/querying/filters) |
||||
|
- [ABP Multi-Tenancy Documentation](https://abp.io/docs/latest/framework/fundamentals/multi-tenancy) |
||||
|
- [Using User-defined function mapping for global filters](https://abp.io/docs/latest/framework/infrastructure/data-filtering#using-user-defined-function-mapping-for-global-filters) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## References |
||||
|
|
||||
|
- [ABP Framework Documentation](https://docs.abp.io) |
||||
|
- [Entity Framework Core Documentation](https://docs.microsoft.com/en-us/ef/core/) |
||||
|
- [EF Core Global Query Filters](https://learn.microsoft.com/en-us/ef/core/querying/filters) |
||||
|
- [User-defined Function Mapping](https://learn.microsoft.com/en-us/ef/core/querying/user-defined-function-mapping) |
||||
@ -0,0 +1 @@ |
|||||
|
Global query filters in Entity Framework Core allow automatic data filtering at the entity level. This article covers ABP Framework's data filtering system, including built-in filters (ISoftDelete, IMultiTenant), custom filter implementation, and performance optimization using user-defined functions. |
||||
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 903 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 19 KiB |
@ -0,0 +1,488 @@ |
|||||
|
# Using OpenAI's Moderation API in an ABP Application with the AI Management Module |
||||
|
|
||||
|
If your application accepts user-generated content (comments, reviews, forum posts) you likely need some form of content moderation. Building one from scratch typically means training ML models, maintaining datasets, and writing a lot of code. OpenAI's `omni-moderation-latest` model offers a practical shortcut: it's free, requires no training data, and covers 13+ harm categories across text and images in 40+ languages. |
||||
|
|
||||
|
In this article, I'll show you how to integrate this model into an ABP application using the [**AI Management Module**](https://abp.io/docs/latest/modules/ai-management). We'll wire it into the [CMS Kit Module's Comment Feature](https://abp.io/docs/latest/modules/cms-kit/comments) so every comment is automatically screened before it's published. The **AI Management Module** handles the OpenAI configuration (API keys, model selection, etc.) through a runtime UI, so you won't need to hardcode any of that into your `appsettings.json` or redeploy when something changes. |
||||
|
|
||||
|
By the end, you'll have a working content moderation pipeline you can adapt for any entity in your ABP project. |
||||
|
|
||||
|
## Understanding OpenAI's Omni-Moderation Model |
||||
|
|
||||
|
Before diving into the implementation, let's understand what makes OpenAI's `omni-moderation-latest` model a game-changer for content moderation. |
||||
|
|
||||
|
### What is it? |
||||
|
|
||||
|
OpenAI's `omni-moderation-latest` is a next-generation multimodal content moderation model built on the foundation of GPT-4o. Released in September 2024, this model represents a significant leap forward in automated content moderation capabilities. |
||||
|
|
||||
|
The most remarkable aspect? **It's completely free to use** through OpenAI's Moderation API, there are no token costs, no usage limits for reasonable use cases, and no hidden fees. |
||||
|
|
||||
|
### Key Capabilities |
||||
|
|
||||
|
The **omni-moderation** model offers several compelling features that make it ideal for production applications: |
||||
|
|
||||
|
- **Multimodal Understanding**: Unlike text-only moderation systems, this model *can process both text and image inputs*, making it suitable for applications where users can upload images alongside their comments or posts. |
||||
|
- **High Accuracy**: Built on GPT-4o's advanced understanding capabilities, the model achieves significantly higher accuracy in detecting nuanced harmful content compared to rule-based systems or simpler ML models. |
||||
|
- **Multilingual Support**: The model demonstrates enhanced performance across more than 40 languages, making it suitable for global applications without requiring separate moderation systems for each language. |
||||
|
- **Comprehensive Category Coverage**: Rather than just detecting "spam" or "not spam," the model classifies content across 13+ distinct categories of potentially harmful content. |
||||
|
|
||||
|
### Content Categories |
||||
|
|
||||
|
The model evaluates content against the following categories, each designed to catch specific types of harmful content: |
||||
|
|
||||
|
| Category | What It Detects | |
||||
|
|----------|-----------------| |
||||
|
| `harassment` | Content that expresses, incites, or promotes harassing language towards any individual or group | |
||||
|
| `harassment/threatening` | Harassment content that additionally includes threats of violence or serious harm | |
||||
|
| `hate` | Content that promotes hate based on race, gender, ethnicity, religion, nationality, sexual orientation, disability, or caste | |
||||
|
| `hate/threatening` | Hateful content that includes threats of violence or serious harm towards the targeted group | |
||||
|
| `self-harm` | Content that promotes, encourages, or depicts acts of self-harm such as suicide, cutting, or eating disorders | |
||||
|
| `self-harm/intent` | Content where the speaker expresses intent to engage in self-harm | |
||||
|
| `self-harm/instructions` | Content that provides instructions or advice on how to commit acts of self-harm | |
||||
|
| `sexual` | Content meant to arouse sexual excitement, including descriptions of sexual activity or promotion of sexual services | |
||||
|
| `sexual/minors` | Sexual content that involves individuals under 18 years of age | |
||||
|
| `violence` | Content that depicts death, violence, or physical injury in graphic detail | |
||||
|
| `violence/graphic` | Content depicting violence or physical injury in extremely graphic, disturbing detail | |
||||
|
| `illicit` | Content that provides advice or instructions for committing illegal activities | |
||||
|
| `illicit/violent` | Illicit content that specifically involves violence or weapons | |
||||
|
|
||||
|
### API Response Structure |
||||
|
|
||||
|
When you send content to the Moderation API (through model or directly to the API), you receive a structured response containing: |
||||
|
|
||||
|
- **`flagged`**: A boolean indicating whether the content violates any of OpenAI's usage policies. This is your primary indicator for whether to block content. |
||||
|
- **`categories`**: A dictionary containing boolean flags for each category, telling you exactly which policies were violated. |
||||
|
- **`category_scores`**: Confidence scores ranging from 0 to 1 for each category, allowing you to implement custom thresholds if needed. |
||||
|
- **`category_applied_input_types`**: A dictionary containing information on which input types were flagged for each category. For example, if both the image and text inputs to the model are flagged for "violence/graphic", the `violence/graphic` property will be set to `["image", "text"]`. This is only available on omni models. |
||||
|
|
||||
|
> For more detailed information about the model's capabilities and best practices, refer to the [OpenAI Moderation Guide](https://platform.openai.com/docs/guides/moderation). |
||||
|
|
||||
|
## The AI Management Module: Your Dynamic AI Configuration Hub |
||||
|
|
||||
|
The [AI Management Module](https://abp.io/docs/latest/modules/ai-management) is a powerful addition to the ABP Platform that transforms how you integrate and manage AI capabilities in your applications. Built on top of the [ABP Framework's AI infrastructure](https://abp.io/docs/latest/framework/infrastructure/artificial-intelligence), it provides a complete solution for managing AI workspaces dynamically—without requiring code changes or application redeployment. |
||||
|
|
||||
|
### Why Use the AI Management Module? |
||||
|
|
||||
|
Traditional AI integrations often suffer from several pain points: |
||||
|
|
||||
|
1. **Hardcoded Configuration**: API keys, model names, and endpoints are typically stored in configuration files, requiring redeployment for any changes. |
||||
|
2. **No Runtime Flexibility**: Switching between AI providers or models requires code changes. |
||||
|
3. **Security Concerns**: Managing API keys across environments is cumbersome and error-prone. |
||||
|
4. **Limited Visibility**: There's no easy way to see which AI configurations are active or test them without writing code. |
||||
|
|
||||
|
The AI Management Module addresses all these concerns by providing: |
||||
|
|
||||
|
- **Dynamic Workspace Management**: Create, configure, and update AI workspaces directly from a user-friendly administrative interface—no code changes required. |
||||
|
- **Provider Flexibility**: Seamlessly switch between different AI providers (OpenAI, Gemini, Antrophic, Azure OpenAI, Ollama, and custom providers) without modifying your application code. |
||||
|
- **Built-in Testing**: Test your AI configurations immediately using the included chat interface playground before deploying to production. |
||||
|
- **Permission-Based Access Control**: Define granular permissions to control who can manage AI workspaces and who can use specific AI features. |
||||
|
- **Multi-Framework Support**: Full support for MVC/Razor Pages, Blazor (Server & WebAssembly), and Angular UI frameworks. |
||||
|
|
||||
|
### Built-in Provider Support |
||||
|
|
||||
|
The **AI Management Module** comes with built-in support for popular AI providers through dedicated NuGet packages: |
||||
|
|
||||
|
- **`Volo.AIManagement.OpenAI`**: Provides seamless integration with OpenAI's APIs, including GPT models and the *Moderation API*. |
||||
|
- Custom providers can be added by implementing the `IChatClientFactory` interface. (If you configured the Ollama while creating your project, then you can see the example implementation for Ollama) |
||||
|
|
||||
|
## Building the Demo Application |
||||
|
|
||||
|
Now let's put theory into practice by building a complete content moderation system. We'll create an ABP application with the **AI Management Module**, configure OpenAI as our provider, set up the CMS Kit Comment Feature, and implement automatic content moderation for all user comments. |
||||
|
|
||||
|
### Step 1: Creating an Application with AI Management Module |
||||
|
|
||||
|
> In this tutorial, I'll create a **layered MVC application** named **ContentModeration**. If you already have an existing solution, you can follow along by replacing the namespaces accordingly. Otherwise, feel free to follow the solution creation steps below. |
||||
|
|
||||
|
The most straightforward way to create an application with the AI Management Module is through **ABP Studio**. When you create a new project, you'll encounter an **AI Integration** step in the project creation wizard. This wizard allows you to: |
||||
|
|
||||
|
- Enable the AI Management Module with a single checkbox |
||||
|
- Configure your preferred AI provider (OpenAI and Ollama) |
||||
|
- Set up initial workspace configurations |
||||
|
- Automatically install all required NuGet packages |
||||
|
|
||||
|
> **Note:** The AI Integration tab in ABP Studio currently only supports the **MVC/Razor Pages** UI. Support for **Angular** and **Blazor** UIs will be added in upcoming versions. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
During the wizard, select **OpenAI** as your AI provider, set the model name as `omni-moderation-latest` and provide your API key. The wizard will automatically: |
||||
|
|
||||
|
1. Install the `Volo.AIManagement.*` packages across your solution |
||||
|
2. Install the `Volo.AIManagement.OpenAI` package for OpenAI provider support (you can use any OpenAI compatible model here, including Gemini, Claude and GPT models) |
||||
|
3. Configure the necessary module dependencies |
||||
|
4. Set up initial database migrations |
||||
|
|
||||
|
**Alternative Installation Method:** |
||||
|
|
||||
|
If you have an existing project or prefer manual installation, you can add the module using the ABP CLI: |
||||
|
|
||||
|
```bash |
||||
|
abp add-module Volo.AIManagement |
||||
|
``` |
||||
|
|
||||
|
Or through ABP Studio by right-clicking on your solution, selecting **Import Module**, and choosing `Volo.AIManagement` from the NuGet tab. |
||||
|
|
||||
|
### Step 2: Understanding the OpenAI Workspace Configuration |
||||
|
|
||||
|
After creating your project and running the application for the first time, navigate to **AI Management > Workspaces** in the admin menu. Here you'll find the workspace management interface where you can view, create, and modify AI workspaces. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
If you configured OpenAI during the project creation wizard, you'll already have a workspace set up. Otherwise, you can create a new workspace with the following configuration: |
||||
|
|
||||
|
| Property | Value | Description | |
||||
|
|----------|-------|-------------| |
||||
|
| **Name** | `OpenAIAssistant` | A unique identifier for this workspace (no spaces allowed) | |
||||
|
| **Provider** | `OpenAI` | The AI provider to use | |
||||
|
| **Model** | `omni-moderation-latest` | The specific model for content moderation | |
||||
|
| **API Key** | `<Your-OpenAI-API-key>` | Authentication credential for the OpenAI API | |
||||
|
| **Description** | `Workspace for content moderation` | A helpful description for administrators | |
||||
|
|
||||
|
The beauty of this approach is that you can modify any of these settings at runtime through the UI. Need to rotate your API key? Just update it in the workspace configuration. Want to test a different model? Change it without touching your code. |
||||
|
|
||||
|
### Step 3: Setting Up the CMS Kit Comment Feature |
||||
|
|
||||
|
Now let's add the CMS Kit Module to enable the Comment Feature. The CMS Kit provides a robust, production-ready commenting system that we'll enhance with our content moderation. |
||||
|
|
||||
|
**Install the CMS Kit Module:** |
||||
|
|
||||
|
Run the following command in your solution directory: |
||||
|
|
||||
|
```bash |
||||
|
abp add-module Volo.CmsKit --skip-db-migrations |
||||
|
``` |
||||
|
|
||||
|
> Also, you can add the related module through ABP Studio UI. |
||||
|
|
||||
|
**Enable the Comment Feature:** |
||||
|
|
||||
|
By default, CMS Kit features are disabled to keep your application lean. Open the `GlobalFeatureConfigurator` class in your `*.Domain.Shared` project and enable the Comment Feature: |
||||
|
|
||||
|
```csharp |
||||
|
using Volo.Abp.GlobalFeatures; |
||||
|
using Volo.Abp.Threading; |
||||
|
|
||||
|
namespace ContentModeration; |
||||
|
|
||||
|
public static class ContentModerationGlobalFeatureConfigurator |
||||
|
{ |
||||
|
private static readonly OneTimeRunner OneTimeRunner = new OneTimeRunner(); |
||||
|
|
||||
|
public static void Configure() |
||||
|
{ |
||||
|
OneTimeRunner.Run(() => |
||||
|
{ |
||||
|
GlobalFeatureManager.Instance.Modules.CmsKit(cmsKit => |
||||
|
{ |
||||
|
//only enable the Comment Feature |
||||
|
cmsKit.Comments.Enable(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Configure the Comment Entity Types:** |
||||
|
|
||||
|
Open your `*DomainModule` class and configure which entity types can have comments. For our demo, we'll enable comments on "Article" entities: |
||||
|
|
||||
|
```csharp |
||||
|
using Volo.CmsKit.Comments; |
||||
|
|
||||
|
// In your ConfigureServices method: |
||||
|
Configure<CmsKitCommentOptions>(options => |
||||
|
{ |
||||
|
options.EntityTypes.Add(new CommentEntityTypeDefinition("Article")); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
**Add the Comment Component to a Page:** |
||||
|
|
||||
|
Finally, let's add the commenting interface to a page. Open the `Index.cshtml` file in your `*.Web` project and add the Comment component (replace with the following content): |
||||
|
|
||||
|
```html |
||||
|
@page |
||||
|
@using Volo.CmsKit.Public.Web.Pages.CmsKit.Shared.Components.Commenting |
||||
|
@model ContentModeration.Web.Pages.IndexModel |
||||
|
|
||||
|
<div class="container mt-4"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h3>Welcome to Our Community</h3> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<p class="lead"> |
||||
|
Share your thoughts in the comments below. Our AI-powered moderation system |
||||
|
automatically reviews all comments to ensure a safe and respectful environment |
||||
|
for everyone. |
||||
|
</p> |
||||
|
|
||||
|
<hr/> |
||||
|
|
||||
|
<h4>Comments</h4> |
||||
|
@await Component.InvokeAsync(typeof(CommentingViewComponent), new |
||||
|
{ |
||||
|
entityType = "Article", |
||||
|
entityId = "welcome-article", |
||||
|
isReadOnly = false |
||||
|
}) |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
``` |
||||
|
|
||||
|
At this point, you have a fully functional commenting system. Users can post comments, reply to existing comments, and interact with the community. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
However, there's no content moderation yet and any content, including harmful content, would be accepted. Let's fix that! |
||||
|
|
||||
|
## Implementing the Content Moderation Service |
||||
|
|
||||
|
**Now comes the exciting part:** implementing the content moderation service that leverages OpenAI's `omni-moderation` model to automatically screen all comments before they're published. |
||||
|
|
||||
|
### Understanding the Architecture |
||||
|
|
||||
|
Our implementation follows a clean, modular architecture: |
||||
|
|
||||
|
1. **`IContentModerator` Interface**: Defines the contract for content moderation, making our implementation testable and replaceable. |
||||
|
2. **`ContentModerator` Service**: The concrete implementation that calls OpenAI's Moderation API using the configuration from the AI Management Module. |
||||
|
3. **`MyCommentAppService`**: An override of the CMS Kit's comment service that integrates our moderation logic. |
||||
|
|
||||
|
This separation of concerns ensures that: |
||||
|
|
||||
|
- The moderation logic is isolated and can be unit tested independently |
||||
|
- You can easily swap the moderation implementation (e.g., switch to a different provider) |
||||
|
- The integration with CMS Kit is clean and maintainable |
||||
|
|
||||
|
### Creating the Content Moderator Interface |
||||
|
|
||||
|
First, let's define the interface in your `*.Application.Contracts` project. This interface is intentionally simple and it takes text input and throws an exception if the content is harmful: |
||||
|
|
||||
|
```csharp |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace ContentModeration.Moderation; |
||||
|
|
||||
|
public interface IContentModerator |
||||
|
{ |
||||
|
Task CheckAsync(string text); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Implementing the Content Moderator Service |
||||
|
|
||||
|
Now let's implement the service in your `*.Application` project. This implementation uses the `IWorkspaceConfigurationStore` from the AI Management Module to dynamically retrieve the OpenAI configuration: |
||||
|
|
||||
|
```csharp |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using OpenAI.Moderations; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.AIManagement.Workspaces.Configuration; |
||||
|
|
||||
|
namespace ContentModeration.Moderation; |
||||
|
|
||||
|
public class ContentModerator : IContentModerator, ITransientDependency |
||||
|
{ |
||||
|
private readonly IWorkspaceConfigurationStore _workspaceConfigurationStore; |
||||
|
|
||||
|
public ContentModerator(IWorkspaceConfigurationStore workspaceConfigurationStore) |
||||
|
{ |
||||
|
_workspaceConfigurationStore = workspaceConfigurationStore; |
||||
|
} |
||||
|
|
||||
|
public async Task CheckAsync(string text) |
||||
|
{ |
||||
|
// Skip moderation for empty content |
||||
|
if (string.IsNullOrWhiteSpace(text)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Retrieve the workspace configuration from AI Management Module |
||||
|
// This allows runtime configuration changes without redeployment |
||||
|
var config = await _workspaceConfigurationStore.GetOrNullAsync<OpenAIAssistantWorkspace>(); |
||||
|
|
||||
|
if(config == null) |
||||
|
{ |
||||
|
throw new UserFriendlyException("Could not find the 'OpenAIAssistant' workspace!"); |
||||
|
} |
||||
|
|
||||
|
var client = new ModerationClient( |
||||
|
model: config.Model, |
||||
|
apiKey: config.ApiKey |
||||
|
); |
||||
|
|
||||
|
// Send the text to OpenAI's Moderation API |
||||
|
var result = await client.ClassifyTextAsync(text); |
||||
|
var moderationResult = result.Value; |
||||
|
|
||||
|
// If the content is flagged, throw a user-friendly exception |
||||
|
if (moderationResult.Flagged) |
||||
|
{ |
||||
|
var flaggedCategories = GetFlaggedCategories(moderationResult); |
||||
|
|
||||
|
throw new UserFriendlyException( |
||||
|
$"Your comment contains content that violates our community guidelines. " + |
||||
|
$"Detected issues: {string.Join(", ", flaggedCategories)}. " + |
||||
|
$"Please revise your comment and try again." |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static List<string> GetFlaggedCategories(ModerationResult result) |
||||
|
{ |
||||
|
var flaggedCategories = new List<string>(); |
||||
|
|
||||
|
if (result.Harassment.Flagged) |
||||
|
{ |
||||
|
flaggedCategories.Add("harassment"); |
||||
|
} |
||||
|
if (result.HarassmentThreatening.Flagged) |
||||
|
{ |
||||
|
flaggedCategories.Add("threatening harassment"); |
||||
|
} |
||||
|
|
||||
|
//other categories... |
||||
|
|
||||
|
return flaggedCategories; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Note**: The `ModerationResult` class from the OpenAI .NET SDK provides properties for each moderation category (e.g., `Harassment`, `Violence`, `Sexual`), each with a `Flagged` boolean and a `Score` float (0-1). The exact property names may vary slightly between SDK versions, so check the [OpenAI .NET SDK documentation](https://github.com/openai/openai-dotnet) for the latest API. |
||||
|
|
||||
|
### Integrating with CMS Kit Comments |
||||
|
|
||||
|
The final piece of the puzzle is integrating our moderation service with the CMS Kit's comment system. We'll override the `CommentPublicAppService` to intercept all comment creation and update requests: |
||||
|
|
||||
|
```csharp |
||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using ContentModeration.Moderation; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.EventBus.Distributed; |
||||
|
using Volo.CmsKit.Comments; |
||||
|
using Volo.CmsKit.Public.Comments; |
||||
|
using Volo.CmsKit.Users; |
||||
|
using Volo.Abp.SettingManagement; |
||||
|
|
||||
|
namespace ContentModeration.Comments; |
||||
|
|
||||
|
[Dependency(ReplaceServices = true)] |
||||
|
[ExposeServices(typeof(ICommentPublicAppService), typeof(CommentPublicAppService), typeof(MyCommentAppService))] |
||||
|
public class MyCommentAppService : CommentPublicAppService |
||||
|
{ |
||||
|
protected IContentModerator ContentModerator { get; } |
||||
|
|
||||
|
public MyCommentAppService( |
||||
|
ICommentRepository commentRepository, |
||||
|
ICmsUserLookupService cmsUserLookupService, |
||||
|
IDistributedEventBus distributedEventBus, |
||||
|
CommentManager commentManager, |
||||
|
IOptionsSnapshot<CmsKitCommentOptions> cmsCommentOptions, |
||||
|
ISettingManager settingManager, |
||||
|
IContentModerator contentModerator) |
||||
|
: base(commentRepository, cmsUserLookupService, distributedEventBus, commentManager, cmsCommentOptions, settingManager) |
||||
|
{ |
||||
|
ContentModerator = contentModerator; |
||||
|
} |
||||
|
|
||||
|
public override async Task<CommentDto> CreateAsync(string entityType, string entityId, CreateCommentInput input) |
||||
|
{ |
||||
|
// Check for harmful content BEFORE creating the comment |
||||
|
// If harmful content is detected, an exception is thrown and the comment is not saved |
||||
|
await ContentModerator.CheckAsync(input.Text); |
||||
|
|
||||
|
return await base.CreateAsync(entityType, entityId, input); |
||||
|
} |
||||
|
|
||||
|
public override async Task<CommentDto> UpdateAsync(Guid id, UpdateCommentInput input) |
||||
|
{ |
||||
|
// Check for harmful content BEFORE updating the comment |
||||
|
// This prevents users from editing approved comments to add harmful content |
||||
|
await ContentModerator.CheckAsync(input.Text); |
||||
|
|
||||
|
return await base.UpdateAsync(id, input); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**How This Works:** |
||||
|
|
||||
|
1. When a user submits a new comment, the `CreateAsync` method is called. |
||||
|
2. Before the comment is saved to the database, we call `ContentModerator.CheckAsync()` with the comment text. |
||||
|
3. The moderation service sends the text to OpenAI's Moderation API. |
||||
|
4. If the content is flagged as harmful, a `UserFriendlyException` is thrown with a descriptive message. |
||||
|
5. The exception is caught by ABP's exception handling middleware and displayed to the user as a friendly error message. |
||||
|
6. If the content passes moderation, the comment is saved normally. |
||||
|
|
||||
|
The same flow applies to comment updates, ensuring users can't circumvent moderation by editing previously approved comments. |
||||
|
|
||||
|
Here's the full flow in action — submitting a comment with harmful content and seeing the moderation kick in: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## The Power of Dynamic Configuration - What AI Management Module Provides to You? |
||||
|
|
||||
|
One of the most significant advantages of using the AI Management Module is the ability to manage your AI configurations dynamically. Let's explore what this means in practice. |
||||
|
|
||||
|
### Runtime Configuration Changes |
||||
|
|
||||
|
With the AI Management Module, you can: |
||||
|
|
||||
|
- **Rotate API Keys**: Update your OpenAI API key through the admin UI without any downtime or redeployment. This is crucial for security compliance and key rotation policies. |
||||
|
- **Switch Models**: Want to test a newer moderation model? Simply update the model name in the workspace configuration. Your application will immediately start using the new model. |
||||
|
- **Adjust Settings**: Fine-tune settings like temperature or system prompts (for chat-based workspaces) without touching your codebase. |
||||
|
- **Enable/Disable Workspaces**: Temporarily disable a workspace for maintenance or testing without affecting other parts of your application. |
||||
|
|
||||
|
### Multi-Environment Management |
||||
|
|
||||
|
The dynamic configuration approach shines in multi-environment scenarios: |
||||
|
|
||||
|
- **Development**: Use a test API key with lower rate limits |
||||
|
- **Staging**: Use a separate API key for integration testing |
||||
|
- **Production**: Use your production API key with appropriate security measures |
||||
|
|
||||
|
All these configurations can be managed through the UI or via data seeding, without environment-specific code changes. |
||||
|
|
||||
|
### Actively Maintained & What's Coming Next |
||||
|
|
||||
|
The AI Management Module is **actively maintained** and continuously evolving. The team is working on exciting new capabilities that will further expand what you can do with AI in your ABP applications: |
||||
|
|
||||
|
- **MCP (Model Context Protocol) Support** — Coming in **v10.2**, MCP support will allow your AI workspaces to interact with external tools and data sources, enabling more sophisticated AI-powered workflows. |
||||
|
- **RAG (Retrieval-Augmented Generation) System** — Also planned for **v10.2**, the built-in RAG system will let you ground AI responses in your own data, making AI features more accurate and context-aware. |
||||
|
- **And More** — Additional features and improvements are on the roadmap to make AI integration even more seamless. |
||||
|
|
||||
|
Since the module is built on ABP's modular architecture, adopting these new capabilities will be straightforward — you can simply update the module and start using the new features without rewriting your existing AI integrations. |
||||
|
|
||||
|
### Permission-Based Access Control |
||||
|
|
||||
|
The AI Management Module integrates with ABP's permission system, allowing you to: |
||||
|
|
||||
|
- Restrict who can view AI workspace configurations |
||||
|
- Control who can create or modify workspaces |
||||
|
- Limit access to specific workspaces based on user roles |
||||
|
|
||||
|
This ensures that sensitive configurations like API keys are only accessible to authorized administrators. |
||||
|
|
||||
|
## Conclusion |
||||
|
|
||||
|
In this comprehensive guide, we've built a production-ready content moderation system that combines the power of OpenAI's `omni-moderation-latest` model with the flexibility of ABP's AI Management Module. Let's recap what makes this approach powerful: |
||||
|
|
||||
|
### Key Takeaways |
||||
|
|
||||
|
1. **Zero Training Required**: Unlike traditional ML approaches that require collecting datasets, training models, and ongoing maintenance, OpenAI's Moderation API works out of the box with state-of-the-art accuracy. |
||||
|
2. **Completely Free**: OpenAI's Moderation API has no token costs, making it economically viable for applications of any scale. |
||||
|
3. **Comprehensive Detection**: With 13+ categories of harmful content detection, you get protection against harassment, hate speech, violence, sexual content, self-harm, and more—all from a single API call. |
||||
|
4. **Dynamic Configuration**: The AI Management Module allows you to manage API keys, switch providers, and adjust settings at runtime without code changes or redeployment. |
||||
|
5. **Clean Integration**: By following ABP's service override pattern, we integrated moderation seamlessly into the existing CMS Kit comment system without modifying the original module. |
||||
|
6. **Production Ready**: The implementation includes proper error handling, graceful degradation, and user-friendly error messages suitable for production use. |
||||
|
|
||||
|
### Resources |
||||
|
|
||||
|
- [AI Management Module Documentation](https://abp.io/docs/latest/modules/ai-management) |
||||
|
- [OpenAI Moderation Guide](https://platform.openai.com/docs/guides/moderation) |
||||
|
- [CMS Kit Comments Feature](https://abp.io/docs/latest/modules/cms-kit/comments) |
||||
|
- [ABP Framework AI Infrastructure](https://abp.io/docs/latest/framework/infrastructure/artificial-intelligence) |
||||
|
After Width: | Height: | Size: 1.4 MiB |
@ -0,0 +1,566 @@ |
|||||
|
# ABP Framework's Hidden Magic: Things That Just Work Without You Knowing |
||||
|
|
||||
|
The ABP Framework is famous for its Convention-over-Configuration approach, which means a lot of things work automatically without explicit configuration. In this article, I'll uncover these "hidden magics" that make ABP so powerful but often go unnoticed by developers. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. Automatic Service Registration Without Any Attributes |
||||
|
|
||||
|
**The Magic:** Any class implementing `ITransientDependency`, `ISingletonDependency`, or `IScopedDependency` is automatically registered with the corresponding lifetime. |
||||
|
|
||||
|
```csharp |
||||
|
// This is automatically registered as Transient - no configuration needed! |
||||
|
public class MyService : IMyService, ITransientDependency |
||||
|
{ |
||||
|
public void DoSomething() { } |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Core/Volo/Abp/DependencyInjection/ConventionalRegistrarBase.cs` |
||||
|
|
||||
|
The framework scans all assemblies and automatically determines service lifetime from class hierarchy. This is why you rarely need to manually register services in ABP. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. All Interfaces Are Exposed By Default |
||||
|
|
||||
|
**The Magic:** When you register a service, it's automatically registered as both itself AND all its implemented interfaces. |
||||
|
|
||||
|
```csharp |
||||
|
public class UserService : IUserService, IValidationInterceptor |
||||
|
{ |
||||
|
// Registered as both IUserService AND IValidationInterceptor |
||||
|
// No ExposeServices attribute needed! |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Core/DependencyInjection/ExposedServiceExplorer.cs:9-14` |
||||
|
|
||||
|
```csharp |
||||
|
private static readonly ExposeServicesAttribute DefaultExposeServicesAttribute = |
||||
|
new ExposeServicesAttribute |
||||
|
{ |
||||
|
IncludeDefaults = true, |
||||
|
IncludeSelf = true |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. Automatic Validation on Every Method |
||||
|
|
||||
|
**The Magic:** Every application service method parameters are automatically validated - you don't need to add `[Validate]` attributes. |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Validation/ValidationInterceptorRegistrar.cs` |
||||
|
|
||||
|
The `ValidationInterceptor` is automatically added to the interceptor pipeline for all services. Every method call triggers automatic validation of input parameters. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. Automatic Unit of Work Management |
||||
|
|
||||
|
**The Magic:** Every database operation is automatically wrapped in a transaction. You don't need to explicitly configure unit of work for most scenarios. |
||||
|
|
||||
|
**Where it happens:** The `UnitOfWorkInterceptor` is auto-registered and automatically: |
||||
|
- Begins transaction before method execution |
||||
|
- Commits on success |
||||
|
- Rolls back on exception |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. Auditing Is Enabled By Default |
||||
|
|
||||
|
**The Magic:** Auditing is **ON** by default, even for anonymous users! |
||||
|
|
||||
|
```csharp |
||||
|
public class AbpAuditingOptions |
||||
|
{ |
||||
|
public AbpAuditingOptions() |
||||
|
{ |
||||
|
IsEnabled = true; // Enabled by default! |
||||
|
IsEnabledForAnonymousUsers = true; // Anonymous users are audited! |
||||
|
HideErrors = true; // Errors are silently hidden |
||||
|
AlwaysLogOnException = true; // Exceptions always logged |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Auditing/AbpAuditingOptions.cs:73-91` |
||||
|
|
||||
|
This means every entity change and service call is logged automatically unless explicitly disabled. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. Security Logging Is Always On |
||||
|
|
||||
|
**The Magic:** Security logging is enabled by default in ABP! |
||||
|
|
||||
|
```csharp |
||||
|
public AbpSecurityLogOptions() |
||||
|
{ |
||||
|
IsEnabled = true; // Hidden: ON by default! |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Every authentication attempt, authorization failure, and security-relevant action is logged automatically. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. Data Filters Are Enabled By Default |
||||
|
|
||||
|
**The Magic:** `ISoftDelete` and `IMultiTenant` filters are **enabled by default**. |
||||
|
|
||||
|
```csharp |
||||
|
// In DataFilter.cs - Line 103 |
||||
|
_filter.Value = _options.DefaultStates.GetOrDefault(typeof(TFilter))?.Clone() |
||||
|
?? new DataFilterState(true); // true = enabled! |
||||
|
``` |
||||
|
|
||||
|
This means: |
||||
|
- Deleted entities are automatically filtered out |
||||
|
- Multi-tenant data is automatically isolated |
||||
|
|
||||
|
You must explicitly **disable** these filters when you need to access all data: |
||||
|
|
||||
|
```csharp |
||||
|
using (_dataFilter.Disable<IMultiTenant>()) |
||||
|
{ |
||||
|
// Query all tenants |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. Object Mapping (Mapperly - The New Standard) |
||||
|
|
||||
|
**The Magic:** Starting with **ABP v9.0**, new project templates use **Mapperly** instead of AutoMapper. Any class using Mapperly attributes is automatically configured. |
||||
|
|
||||
|
```csharp |
||||
|
// Starting with ABP v10.0, new projects use Mapperly instead of AutoMapper |
||||
|
|
||||
|
// Inherit from MapperBase - automatically registered with IObjectMapper |
||||
|
public partial class UserMapper : MapperBase<User, UserDto> |
||||
|
{ |
||||
|
public override partial UserDto Map(User source); |
||||
|
} |
||||
|
|
||||
|
// For two-way mapping |
||||
|
public partial class UserTwoWayMapper : TwoWayMapperBase<User, UserDto> |
||||
|
{ |
||||
|
public override partial UserDto Map(User source); |
||||
|
public override partial User ReverseMap(UserDto source); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
The mapping is done at **compile-time** (no reflection overhead), and it's automatically registered with ABP's `IObjectMapper`. |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Mapperly/AbpMapperlyConventionalRegistrar.cs` |
||||
|
|
||||
|
```csharp |
||||
|
// Automatically discovers and configures all Mapperly mappers |
||||
|
context.Services.OnRegistered(context => |
||||
|
{ |
||||
|
if (typeof(MapperBase).IsAssignableFrom(context.ImplementationType)) |
||||
|
{ |
||||
|
// Register the mapper |
||||
|
} |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. Automatic Data Seed Contributor Discovery |
||||
|
|
||||
|
**The Magic:** Any class implementing `IDataSeedContributor` is automatically discovered and executed on application startup. |
||||
|
|
||||
|
```csharp |
||||
|
// Automatically discovered and run on startup! |
||||
|
public class MyDataSeeder : IDataSeedContributor |
||||
|
{ |
||||
|
public Task SeedAsync(DataSeedContext context) |
||||
|
{ |
||||
|
// Seed data here |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Data/AbpDataModule.cs:40-56` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. Automatic Definition Provider Discovery |
||||
|
|
||||
|
**The Magic:** These are all auto-discovered without any configuration: |
||||
|
|
||||
|
- `ISettingDefinitionProvider` - Settings |
||||
|
- `IPermissionDefinitionProvider` - Permissions |
||||
|
- `IFeatureDefinitionProvider` - Features |
||||
|
- `INavigationProvider` - Navigation items |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. Automatic Widget Discovery |
||||
|
|
||||
|
**The Magic:** Any class implementing `IWidget` is automatically registered and can be rendered in pages. |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.AspNetCore.Mvc.UI.Widgets/AbpAspNetCoreMvcUiWidgetsModule.cs` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. Remote Services Are Enabled By Default |
||||
|
|
||||
|
**The Magic:** All API controllers have remote service functionality enabled by default: |
||||
|
|
||||
|
```csharp |
||||
|
public class RemoteServiceAttribute : Attribute |
||||
|
{ |
||||
|
public bool IsEnabled { get; set; } = true; // Enabled by default! |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. Auto API Controllers - Application Services Become REST APIs Automatically |
||||
|
|
||||
|
**The Magic:** When you create an application service (class implementing an interface or inheriting from `ApplicationService`), ABP **automatically** creates REST API endpoints for it - no manual controller needed! |
||||
|
|
||||
|
```csharp |
||||
|
// This interface is automatically exposed as /api/app/product |
||||
|
public interface IProductAppService |
||||
|
{ |
||||
|
Task<List<ProductDto>> GetListAsync(); |
||||
|
Task<ProductDto> CreateAsync(CreateProductDto input); |
||||
|
Task DeleteAsync(Guid id); |
||||
|
} |
||||
|
|
||||
|
// The implementation automatically becomes an API Controller |
||||
|
public class ProductAppService : ApplicationService, IProductAppService |
||||
|
{ |
||||
|
public Task<List<ProductDto>> GetListAsync() { ... } |
||||
|
public Task<ProductDto> CreateAsync(CreateProductDto input) { ... } |
||||
|
public Task DeleteAsync(Guid id) { ... } |
||||
|
} |
||||
|
|
||||
|
// Available endpoints (auto-generated): |
||||
|
// GET /api/app/product |
||||
|
// POST /api/app/product |
||||
|
// DELETE /api/app/product/{id} |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.AspNetCore.Mvc/AbpServiceConvention.cs` |
||||
|
|
||||
|
The framework: |
||||
|
- Converts camelCase method names to kebab-case routes |
||||
|
- Maps HTTP methods automatically (Get→GET, Create→POST, Delete→DELETE) |
||||
|
- Generates proper DTOs from parameters and return types |
||||
|
- Handles serialization/deserialization |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. Dynamic Client Proxies - Client-Side Code Generated Automatically |
||||
|
|
||||
|
**The Magic:** On the client side, you don't need to write HTTP client code. ABP automatically generates **Dynamic JavaScript Proxies** and **Dynamic C# Proxies** that let you call your APIs as if they were local method calls! |
||||
|
|
||||
|
**JavaScript (MVC/Razor Pages):** |
||||
|
```javascript |
||||
|
// Just call it like a local function! |
||||
|
var products = await productAppService.getList(); |
||||
|
await productAppService.create({ name: "New Product" }); |
||||
|
await productAppService.delete(id); |
||||
|
``` |
||||
|
|
||||
|
**C# (Blazor/Console Apps):** |
||||
|
```csharp |
||||
|
// Inject and use like local method calls! |
||||
|
public class ProductListModel : PageModel |
||||
|
{ |
||||
|
private readonly IProductAppService _productAppService; |
||||
|
|
||||
|
public async Task OnGetAsync() |
||||
|
{ |
||||
|
// Actually makes HTTP call to the server! |
||||
|
var products = await _productAppService.GetListAsync(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** |
||||
|
- JavaScript: `Volo.Abp.AspNetCore.Mvc.UI` - Dynamic JavaScript proxies |
||||
|
- C#: `Volo.Abp.AspNetCore.Mvc.Client` - Dynamic C# HTTP clients |
||||
|
|
||||
|
This is why you can inject application service interfaces directly in Blazor and call them like local methods! |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. Permission Checks |
||||
|
|
||||
|
By default, all application service methods and controllers are **public** and accessible. Add `[Authorize]` or `[AbpAuthorize]` to restrict access: |
||||
|
|
||||
|
```csharp |
||||
|
[Authorize] |
||||
|
public async Task CreateAsync(CreateDto input) { } |
||||
|
|
||||
|
[AbpAuthorize("MyApp.Permissions.CanCreate")] |
||||
|
public async Task CreateAsync(CreateDto input) { } |
||||
|
``` |
||||
|
|
||||
|
The `AuthorizationInterceptor` is added only when `[Authorize]` attribute is present on the class or method. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 16. Background Workers Auto-Registration |
||||
|
|
||||
|
**The Magic:** Background workers are enabled by default, and any class implementing `IBackgroundWorker` or `IQuartzBackgroundWorker` is auto-registered. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 17. Entity ID Generation |
||||
|
|
||||
|
**The Magic:** ABP automatically detects the best ID generation strategy based on the entity type: |
||||
|
|
||||
|
- `Guid` → Auto-generates GUID |
||||
|
- `int`/`long` → Database identity |
||||
|
- `string` → No auto-generation (must provide) |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.Ddd.Domain/Entities/EntityHelper.cs` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 18. Anti-Forgery Token Magic |
||||
|
|
||||
|
**The Magic:** ABP automatically handles CSRF protection with these hardcoded values: |
||||
|
|
||||
|
```csharp |
||||
|
// Blazor Client |
||||
|
private const string AntiForgeryCookieName = "XSRF-TOKEN"; |
||||
|
private const string AntiForgeryHeaderName = "RequestVerificationToken"; |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 19. Automatic Event Handler Discovery |
||||
|
|
||||
|
**The Magic:** Any class implementing `IEventHandler<TEvent>` is automatically subscribed to handle events - no manual registration needed! |
||||
|
|
||||
|
```csharp |
||||
|
// This handler is automatically registered when the assembly loads! |
||||
|
public class OrderCreatedHandler : IEventHandler<OrderCreatedEvent> |
||||
|
{ |
||||
|
public Task HandleEventAsync(OrderCreatedEvent eventData) |
||||
|
{ |
||||
|
// Handle the event - automatically subscribed! |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 20. Unit of Work Events - Automatic Save |
||||
|
|
||||
|
**The Magic:** Events are not fired immediately - they're collected during the unit of work and fired at the end when everything succeeds! |
||||
|
|
||||
|
```csharp |
||||
|
// In UnitOfWorkEventPublisher.cs |
||||
|
// Events are queued and published only when UOW successfully completes |
||||
|
await _localEventBus.PublishAsync( |
||||
|
entityChangeEvent, |
||||
|
onUnitOfWorkComplete: true // Wait for UOW to complete! |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
This ensures transactional consistency - if your UOW fails, no events are fired. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 21. Distributed Event Bus - Outbox Pattern |
||||
|
|
||||
|
**The Magic:** ABP implements the Outbox Pattern automatically for distributed events, ensuring no events are lost! |
||||
|
|
||||
|
```csharp |
||||
|
// In DistributedEventBusBase.cs |
||||
|
// Events are stored in outbox table and processed reliably |
||||
|
foreach (var outboxConfig in AbpDistributedEventBusOptions.Outboxes.Values.OrderBy(x => x.Selector is null)) |
||||
|
{ |
||||
|
// Outbox processing happens automatically |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 22. Automatic Object Extension Properties |
||||
|
|
||||
|
**The Magic:** Any property decorated with `[DisableAuditing]` is automatically excluded from audit logs without any configuration! |
||||
|
|
||||
|
```csharp |
||||
|
// This property is automatically excluded from auditing |
||||
|
[DisableAuditing] |
||||
|
public string SecretData { get; set; } |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 23. Virtual File System |
||||
|
|
||||
|
**The Magic:** ABP provides a virtual file system that merges embedded resources from all modules into a single virtual path! |
||||
|
|
||||
|
```csharp |
||||
|
// Any file embedded as "EmbeddedResource" is accessible virtually |
||||
|
// No configuration needed for module authors! |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.VirtualFileSystem/AbpVirtualFileSystemModule.cs` |
||||
|
|
||||
|
This is how ABP modules include static files (CSS, JS, images) that work without copying to wwwroot. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 24. Automatic JSON Serialization Settings |
||||
|
|
||||
|
**The Magic:** ABP pre-configures JSON serialization with: |
||||
|
|
||||
|
- Camel case property naming |
||||
|
- Null value handling |
||||
|
- Reference loop handling |
||||
|
- Custom converters for common types |
||||
|
|
||||
|
All configured automatically. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 26. Localization Automatic Discovery |
||||
|
|
||||
|
**The Magic:** All `.json` localization files in the application are automatically discovered and loaded: |
||||
|
|
||||
|
``` |
||||
|
/Localization/MyApp/ |
||||
|
en.json |
||||
|
tr.json |
||||
|
de.json |
||||
|
``` |
||||
|
|
||||
|
No explicit registration needed - just add files and they're available! |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 27. Feature Checks |
||||
|
|
||||
|
Add `[RequiresFeature]` to restrict access based on feature flags: |
||||
|
|
||||
|
```csharp |
||||
|
[RequiresFeature("MyApp.Features.SomeFeature")] |
||||
|
public async Task DoSomethingAsync() |
||||
|
{ |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
The `FeatureInterceptor` is added only when `[RequiresFeature]` attribute is present on the class or method. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 28. API Versioning Convention |
||||
|
|
||||
|
**The Magic:** ABP automatically handles API versioning with sensible defaults: |
||||
|
|
||||
|
- Default version: `1.0` |
||||
|
- Version from URL path: `/api/v1/...` |
||||
|
- Version from header: `Accept: application/json;v=1.0` |
||||
|
|
||||
|
All configured automatically unless overridden. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 29. Health Check Endpoints |
||||
|
|
||||
|
**The Magic:** Health check endpoints are auto-registered: |
||||
|
|
||||
|
- `/health` - Overall health status |
||||
|
- `/health/ready` - Readiness check |
||||
|
- `/health/live` - Liveness check |
||||
|
|
||||
|
Includes automatic checks for: |
||||
|
- Database connectivity |
||||
|
- Cache availability |
||||
|
- External services |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 30. Swagger/OpenAPI Auto-Configuration |
||||
|
|
||||
|
**The Magic:** If you reference `Volo.Abp.AspNetCore.Mvc.UI.Swagger`, Swagger UI is automatically generated with: |
||||
|
|
||||
|
- All API endpoints documented |
||||
|
- Authorization support |
||||
|
- Versioning support |
||||
|
- XML documentation |
||||
|
|
||||
|
No configuration needed beyond the package reference! |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 31. Background Job Queue Magic |
||||
|
|
||||
|
**The Magic:** Background jobs are automatically retried with exponential backoff: |
||||
|
|
||||
|
```csharp |
||||
|
// Jobs are automatically: |
||||
|
// - Queued when published |
||||
|
// - Retried on failure (3 times default) |
||||
|
// - Delayed with exponential backoff |
||||
|
``` |
||||
|
|
||||
|
**Where it happens:** `Volo.Abp.BackgroundJobs/AbpBackgroundJobOptions.cs` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Summary Table |
||||
|
|
||||
|
| # | Feature | Default Behavior | You Need to Know | |
||||
|
|---|---------|-----------------|------------------| |
||||
|
| 1 | **Service Registration** | Auto by interface | Implement `ITransientDependency` | |
||||
|
| 2 | **Service Exposure** | Self + all interfaces | Default is generous | |
||||
|
| 3 | **Validation** | All methods validated | Happens automatically | |
||||
|
| 4 | **Unit of Work** | Transactional by default | Auto-commits/rollbacks | |
||||
|
| 5 | **Auditing** | Enabled + anonymous users | Can disable per entity/method | |
||||
|
| 6 | **Security Log** | Always on | Can configure what to log | |
||||
|
| 7 | **Soft Delete Filter** | Enabled by default | Must disable to query deleted | |
||||
|
| 8 | **Multi-Tenancy Filter** | Enabled by default | Must disable for host data | |
||||
|
| 9 | **Object Mapping** | Mapperly (compile-time) | Inherit from `MapperBase` | |
||||
|
| 10 | **Data Seeds** | Auto-discovery | Implement `IDataSeedContributor` | |
||||
|
| 11 | **Remote Services** | Enabled by default | Can disable per service/method | |
||||
|
| 12 | **Auto API Controllers** | App services → REST APIs | No manual controller needed | |
||||
|
| 13 | **Dynamic Client Proxies** | Auto-generated | Call APIs like local methods | |
||||
|
| 14 | **Permissions** | NOT automatic | Must add `[Authorize]` | |
||||
|
| 15 | **Settings** | Auto-discovery | Define via `ISettingDefinitionProvider` | |
||||
|
| 16 | **Features** | NOT automatic | Must add `[RequiresFeature]` | |
||||
|
| 17 | **Background Workers** | Auto-registration | Implement `IBackgroundWorker` | |
||||
|
| 18 | **Entity ID Generation** | Auto by type | Guid, int, string strategies | |
||||
|
| 19 | **Anti-Forgery** | Auto-enabled | Token cookie/header handling | |
||||
|
| 20 | **Event Handlers** | Auto-discovery | Implement `IEventHandler<TEvent>` | |
||||
|
| 21 | **UOW Events** | Deferred execution | Transactional consistency | |
||||
|
| 22 | **Distributed Events** | Outbox pattern | Reliable messaging | |
||||
|
| 23 | **Virtual Files** | Module merging | Embedded resources as virtual | |
||||
|
| 24 | **JSON Settings** | Pre-configured | CamelCase, null handling | |
||||
|
| 25 | **Tenant Resolution** | Multi-source chain | Route → Query → Header → Cookie → Subdomain | |
||||
|
| 26 | **Localization** | Auto-discovery | JSON files in /Localization | |
||||
|
| 27 | **API Versioning** | Default v1.0 | URL, header, query support | |
||||
|
| 28 | **Health Checks** | Auto-registered | /health, /health/ready, /health/live | |
||||
|
| 29 | **Swagger** | Auto-generated | With authorization support | |
||||
|
| 30 | **Background Job Queue** | Auto with backoff | 3 retries default | |
||||
|
| 31 | **Widgets** | Auto-discovery | Implement `IWidget` | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Conclusion |
||||
|
|
||||
|
ABP Framework's hidden magic is what makes it so productive to use. These conventions allow developers to focus on business logic rather than boilerplate configuration. However, understanding these defaults is crucial for: |
||||
|
|
||||
|
1. **Debugging** - Knowing why certain behaviors happen |
||||
|
2. **Optimization** - Disabling what you don't need |
||||
|
3. **Security** - Understanding what's logged/audited by default |
||||
|
4. **Architecture** - Following the intended patterns |
||||
|
|
||||
|
The next time something "just works" in ABP, there's likely a hidden convention behind it! |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
*What hidden ABP magic have you discovered? Share your findings in the comments!* |
||||
@ -1,3 +1,178 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Learn how the ABP Framework uses Correlation IDs to trace and correlate operations across HTTP requests, distributed events, audit logs, and microservices." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
# Correlation ID |
# Correlation ID |
||||
|
|
||||
This document is planned to be written later. |
A **Correlation ID** is a unique identifier that is assigned to a request or operation and propagated across all related processing steps. It allows you to **trace** and **correlate** logs, events, and operations that belong to the same logical transaction, even when they span multiple services or components. |
||||
|
|
||||
|
ABP provides a built-in correlation ID infrastructure that: |
||||
|
|
||||
|
- **Automatically assigns** a unique correlation ID to each incoming HTTP request (or uses the one provided by the caller). |
||||
|
- **Propagates** the correlation ID through distributed event bus messages, HTTP client calls, audit logs, security logs, and Serilog log entries. |
||||
|
- **Provides a simple API** (`ICorrelationIdProvider`) to get or change the current correlation ID in your application code. |
||||
|
|
||||
|
## `AbpCorrelationIdMiddleware` |
||||
|
|
||||
|
`AbpCorrelationIdMiddleware` is an ASP.NET Core middleware that handles correlation ID management for HTTP requests. It is automatically added to the request pipeline when you use ABP's application builder. |
||||
|
|
||||
|
The middleware performs the following steps for each incoming HTTP request: |
||||
|
|
||||
|
1. **Reads** the correlation ID from the incoming request's `X-Correlation-Id` header (configurable via `AbpCorrelationIdOptions` as explained below). |
||||
|
2. **Generates** a new correlation ID (`Guid.NewGuid().ToString("N")`) if the request does not contain one. |
||||
|
3. **Sets** the correlation ID in the current async context using `ICorrelationIdProvider`, making it available throughout the request pipeline. |
||||
|
4. **Writes** the correlation ID to the response header (if `SetResponseHeader` option is enabled). |
||||
|
|
||||
|
You can add the middleware to your request pipeline by calling the `UseCorrelationId` extension method: |
||||
|
|
||||
|
```csharp |
||||
|
app.UseCorrelationId(); |
||||
|
``` |
||||
|
|
||||
|
> This is already configured in the application startup template. You typically don't need to add it manually. |
||||
|
|
||||
|
## `ICorrelationIdProvider` |
||||
|
|
||||
|
`ICorrelationIdProvider` is the core service for working with correlation IDs. It allows you to retrieve the current correlation ID or temporarily change it. |
||||
|
|
||||
|
```csharp |
||||
|
public interface ICorrelationIdProvider |
||||
|
{ |
||||
|
string? Get(); |
||||
|
|
||||
|
IDisposable Change(string? correlationId); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- `Get()`: Returns the current correlation ID for the executing context. Returns `null` if no correlation ID has been set. |
||||
|
- `Change(string? correlationId)`: Changes the correlation ID for the current context and returns an `IDisposable` object. When the returned object is disposed, the correlation ID is restored to its previous value. |
||||
|
|
||||
|
### Using `ICorrelationIdProvider` |
||||
|
|
||||
|
You can inject `ICorrelationIdProvider` into any service to access the current correlation ID: |
||||
|
|
||||
|
```csharp |
||||
|
public class MyService : ITransientDependency |
||||
|
{ |
||||
|
public ILogger<MyService> Logger { get; set; } |
||||
|
|
||||
|
private readonly ICorrelationIdProvider _correlationIdProvider; |
||||
|
|
||||
|
public MyService(ICorrelationIdProvider correlationIdProvider) |
||||
|
{ |
||||
|
Logger = NullLogger<MyService>.Instance; |
||||
|
_correlationIdProvider = correlationIdProvider; |
||||
|
} |
||||
|
|
||||
|
public async Task DoSomethingAsync() |
||||
|
{ |
||||
|
// Get the current correlation ID |
||||
|
var correlationId = _correlationIdProvider.Get(); |
||||
|
|
||||
|
// Use it for logging, tracing, etc. |
||||
|
Logger.LogInformation("Processing with Correlation ID: {CorrelationId}", correlationId); |
||||
|
|
||||
|
await SomeOperationAsync(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Changing the Correlation ID |
||||
|
|
||||
|
You can temporarily change the correlation ID using the `Change` method. This is useful when you want to create a new scope with a different correlation ID: |
||||
|
|
||||
|
```csharp |
||||
|
public async Task ProcessAsync() |
||||
|
{ |
||||
|
var currentCorrelationId = _correlationIdProvider.Get(); |
||||
|
// currentCorrelationId = "abc123" |
||||
|
|
||||
|
using (_correlationIdProvider.Change("new-correlation-id")) |
||||
|
{ |
||||
|
var innerCorrelationId = _correlationIdProvider.Get(); |
||||
|
// innerCorrelationId = "new-correlation-id" |
||||
|
} |
||||
|
|
||||
|
var restoredCorrelationId = _correlationIdProvider.Get(); |
||||
|
// restoredCorrelationId = "abc123" (restored to original) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
The `Change` method returns an `IDisposable`. When disposed, the correlation ID is automatically restored to its previous value. This pattern supports nested scopes safely. |
||||
|
|
||||
|
### Default Implementation |
||||
|
|
||||
|
The default implementation (`DefaultCorrelationIdProvider`) uses `AsyncLocal<string?>` to store the correlation ID. This ensures that the correlation ID is isolated per async execution flow and is thread-safe. |
||||
|
|
||||
|
## `AbpCorrelationIdOptions` |
||||
|
|
||||
|
You can configure the correlation ID behavior using `AbpCorrelationIdOptions`: |
||||
|
|
||||
|
```csharp |
||||
|
Configure<AbpCorrelationIdOptions>(options => |
||||
|
{ |
||||
|
options.HttpHeaderName = "X-Correlation-Id"; |
||||
|
options.SetResponseHeader = true; |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- `HttpHeaderName` (default: `"X-Correlation-Id"`): The HTTP header name used to read/write the correlation ID. You can change this if your infrastructure uses a different header name. |
||||
|
- `SetResponseHeader` (default: `true`): If `true`, the middleware automatically adds the correlation ID to the HTTP response headers. Set it to `false` if you don't want to expose the correlation ID in response headers. |
||||
|
|
||||
|
## Correlation ID Across ABP Services |
||||
|
|
||||
|
One of the most valuable aspects of ABP's correlation ID infrastructure is that it **automatically propagates** the correlation ID across various system boundaries. This allows you to trace a single user action as it flows through multiple services and components. |
||||
|
|
||||
|
### HTTP Client Calls |
||||
|
|
||||
|
When you use ABP's [dynamic client proxies](../api-development/dynamic-csharp-clients.md) to call remote services, the correlation ID is automatically added to the outgoing HTTP request headers. This means downstream services will receive the same correlation ID, enabling end-to-end tracing across microservices. |
||||
|
|
||||
|
``` |
||||
|
Client Request (X-Correlation-Id: abc123) |
||||
|
→ Service A (receives abc123, sets in context) |
||||
|
→ Service B via HTTP Client Proxy (forwards abc123 in header) |
||||
|
→ Service C via HTTP Client Proxy (forwards abc123 in header) |
||||
|
``` |
||||
|
|
||||
|
No manual configuration is required. ABP's `ClientProxyBase` automatically reads the current correlation ID from `ICorrelationIdProvider` and adds it as a request header. |
||||
|
|
||||
|
### Distributed Event Bus |
||||
|
|
||||
|
When you publish a [distributed event](./event-bus/distributed/index.md), ABP automatically attaches the current correlation ID to the outgoing event message. When the event is consumed (potentially by a different service), the correlation ID is extracted from the message and set in the consumer's context. |
||||
|
|
||||
|
> This works with all supported event bus providers, including [RabbitMQ](./event-bus/distributed/rabbitmq.md), [Kafka](./event-bus/distributed/kafka.md), [Azure Service Bus](./event-bus/distributed/azure.md) and [Rebus](./event-bus/distributed/rebus.md). |
||||
|
|
||||
|
``` |
||||
|
Service A publishes event (CorrelationId: abc123) |
||||
|
→ Event Bus (carries abc123 in message metadata) |
||||
|
→ Service B receives event (CorrelationId restored to abc123) |
||||
|
``` |
||||
|
|
||||
|
### Audit Logging |
||||
|
|
||||
|
ABP's [audit logging](./audit-logging.md) system automatically captures the current correlation ID when creating audit log entries. This is stored in the `CorrelationId` property of `AuditLogInfo`, allowing you to query and filter audit logs by correlation ID. |
||||
|
|
||||
|
This is particularly useful for: |
||||
|
|
||||
|
- Tracing all database changes made during a single request. |
||||
|
- Correlating audit log entries across multiple services for the same user action. |
||||
|
- Debugging and investigating issues by filtering logs with a specific correlation ID. |
||||
|
|
||||
|
### Security Logging |
||||
|
|
||||
|
Similar to audit logging, ABP's security log system also captures the current correlation ID. When security-related events are logged (such as login attempts, permission checks, etc.), the correlation ID is included in the `SecurityLogInfo.CorrelationId` property. |
||||
|
|
||||
|
### Serilog Integration |
||||
|
|
||||
|
If you use the **ABP Serilog integration**, the correlation ID is automatically added to the Serilog log context as a property. This means every log entry within a request will include the correlation ID, making it easy to filter and search logs. |
||||
|
|
||||
|
The correlation ID is enriched as a log property named `CorrelationId` by default. You can use it in your Serilog output template or structured log queries. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
- [Audit Logging](./audit-logging.md) |
||||
|
- [Distributed Event Bus](./event-bus/distributed/index.md) |
||||
|
- [Dynamic Client Proxies](../api-development/dynamic-csharp-clients.md) |
||||
|
|||||
@ -0,0 +1,162 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Combine backend and UI localizations in Angular: use JSON files to override or extend server-sidtexts with the same key format and abpLocalization pipe." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Hybrid Localization |
||||
|
|
||||
|
Hybrid localization lets you combine **backend localizations** (from the ABP server) with **UI localizations** (JSON files in your Angular app). UI values take priority over backend values for the same key, so you can override or extend server-side texts without changing the backend. |
||||
|
|
||||
|
## How It Works |
||||
|
|
||||
|
- **Backend localizations**: Loaded from the server (e.g. `ApplicationLocalizationResourceDto`). Keys use the format `ResourceName::Key`. |
||||
|
- **UI localizations**: Loaded from static JSON files under your app's assets (e.g. `/assets/localization/en.json`). The same key format `ResourceName::Key` is used. |
||||
|
- **Priority**: When a key exists in both backend and UI, the **UI value is used** (UI overrides backend). |
||||
|
|
||||
|
The existing `abpLocalization` pipe and localization APIs work unchanged; they resolve keys from the merged set (backend + UI), with UI winning on conflicts. |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
Enable hybrid localization in your app config via `provideAbpCore` and `withOptions`: |
||||
|
|
||||
|
```typescript |
||||
|
// app.config.ts |
||||
|
import { provideAbpCore, withOptions } from "@abp/ng.core"; |
||||
|
|
||||
|
export const appConfig: ApplicationConfig = { |
||||
|
providers: [ |
||||
|
provideAbpCore( |
||||
|
withOptions({ |
||||
|
// ...other options |
||||
|
uiLocalization: { |
||||
|
enabled: true, |
||||
|
basePath: "/assets/localization", // optional; default is '/assets/localization' |
||||
|
}, |
||||
|
}), |
||||
|
), |
||||
|
// ... |
||||
|
], |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
| Option | Description | Default | |
||||
|
| ---------- | ---------------------------------------------------------------------------- | ------------------------ | |
||||
|
| `enabled` | Turn on UI localization loading from `{basePath}/{culture}.json`. | — | |
||||
|
| `basePath` | Base path for JSON files. Files are loaded from `{basePath}/{culture}.json`. | `'/assets/localization'` | |
||||
|
|
||||
|
When `enabled` is `true`, the app loads a JSON file for the current language (e.g. `en`, `tr`) whenever the user changes language. Loaded data is merged with backend localizations (UI overrides backend for the same key). |
||||
|
|
||||
|
## UI Localization File Format |
||||
|
|
||||
|
Place one JSON file per culture under your `basePath`. File name must be `{culture}.json` (e.g. `en.json`, `tr.json`). |
||||
|
|
||||
|
Structure: **resource name → key → value**. |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"MyProjectName": { |
||||
|
"Welcome": "Welcome from UI (en.json)", |
||||
|
"CustomKey": "This is a UI-only localization", |
||||
|
"TestMessage": "UI localization is working!" |
||||
|
}, |
||||
|
"AbpAccount": { |
||||
|
"Login": "Sign In (UI Override)" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- Top-level keys are **resource names** (e.g. `MyProjectName`, `AbpAccount`). |
||||
|
- Nested keys are **localization keys**; values are the display strings for that culture. |
||||
|
|
||||
|
In templates you keep using the same key format: `ResourceName::Key`. |
||||
|
|
||||
|
## Using in Templates |
||||
|
|
||||
|
Use the `abpLocalization` pipe as usual. Keys can come from backend only, UI only, or both (UI wins): |
||||
|
|
||||
|
```html |
||||
|
<!-- Backend (if available) or UI --> |
||||
|
<p>{%{{ 'MyProjectName::Welcome' | abpLocalization }}%}</p> |
||||
|
|
||||
|
<!-- UI-only key (from /assets/localization/{{ culture }}.json) --> |
||||
|
<p>{%{{ 'MyProjectName::CustomKey' | abpLocalization }}%}</p> |
||||
|
|
||||
|
<!-- Backend key overridden by UI --> |
||||
|
<p>{%{{ 'AbpAccount::Login' | abpLocalization }}%}</p> |
||||
|
``` |
||||
|
|
||||
|
No template changes are needed; only the configuration and the JSON files. |
||||
|
|
||||
|
## UILocalizationService |
||||
|
|
||||
|
The `UILocalizationService` (`@abp/ng.core`) manages UI localizations and merges them with backend data. |
||||
|
|
||||
|
### Get loaded UI data |
||||
|
|
||||
|
To inspect what was loaded from the UI JSON files (e.g. for debugging or display): |
||||
|
|
||||
|
```typescript |
||||
|
import { UILocalizationService, SessionStateService } from "@abp/ng.core"; |
||||
|
|
||||
|
export class MyComponent { |
||||
|
private uiLocalizationService = inject(UILocalizationService); |
||||
|
private sessionState = inject(SessionStateService); |
||||
|
|
||||
|
currentLanguage$ = this.sessionState.getLanguage$(); |
||||
|
|
||||
|
ngOnInit() { |
||||
|
// All loaded UI resources for current language |
||||
|
const loaded = this.uiLocalizationService.getLoadedLocalizations(); |
||||
|
// Or for a specific culture |
||||
|
const loadedEn = this.uiLocalizationService.getLoadedLocalizations("en"); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`getLoadedLocalizations(culture?: string)` returns an object of the form `{ [resourceName: string]: Record<string, string> }` for the given culture (or current language if omitted). |
||||
|
|
||||
|
### Add translations at runtime |
||||
|
|
||||
|
You can also add or merge UI translations programmatically (e.g. from another source or lazy-loaded module): |
||||
|
|
||||
|
```typescript |
||||
|
this.uiLocalizationService.addAngularLocalizeLocalization( |
||||
|
'en', // culture |
||||
|
'MyProjectName', // resource name |
||||
|
{ MyKey: 'My value' }, // key-value map |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
This merges into the existing UI localizations and is taken into account by the `abpLocalization` pipe with the same UI-over-backend priority. |
||||
|
|
||||
|
## Example: Dev App |
||||
|
|
||||
|
The ABP dev app demonstrates hybrid localization: |
||||
|
|
||||
|
1. **Config** (`app.config.ts`): |
||||
|
|
||||
|
```typescript |
||||
|
uiLocalization: { |
||||
|
enabled: true, |
||||
|
basePath: '/assets/localization', |
||||
|
}, |
||||
|
``` |
||||
|
|
||||
|
2. **Files**: `src/assets/localization/en.json` and `src/assets/localization/tr.json` with the structure shown above. |
||||
|
|
||||
|
3. **Component** (`localization-test.component.ts`): Uses `abpLocalization` for backend keys, UI-only keys, and overrides; and uses `UILocalizationService.getLoadedLocalizations()` to show loaded UI data. |
||||
|
|
||||
|
See `apps/dev-app/src/app/localization-test/localization-test.component.ts` and `apps/dev-app/src/assets/localization/*.json` in the repository for the full example. |
||||
|
|
||||
|
## Summary |
||||
|
|
||||
|
| Topic | Description | |
||||
|
|------------------|-------------| |
||||
|
| **Purpose** | Combine backend and UI localizations; UI overrides backend for the same key. | |
||||
|
| **Config** | `provideAbpCore(withOptions({ uiLocalization: { enabled: true, basePath?: string } }))`. | |
||||
|
| **File location**| `{basePath}/{culture}.json` (e.g. `/assets/localization/en.json`). | |
||||
|
| **JSON format** | `{ "ResourceName": { "Key": "Value", ... }, ... }`. | |
||||
|
| **Template usage** | Same as before: `{%{{ 'ResourceName::Key' \| abpLocalization }}%}`. | |
||||
|
| **API** | `UILocalizationService`: `getLoadedLocalizations(culture?)`, `addAngularLocalizeLocalization(culture, resourceName, translations)`. | |
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 98 KiB |
@ -0,0 +1,149 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Define custom REST API endpoints with JavaScript handlers in the ABP Low-Code System. Create dynamic APIs without writing C# controllers." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Custom Endpoints |
||||
|
|
||||
|
Custom Endpoints allow you to define REST API routes with server-side JavaScript handlers directly in `model.json`. Each endpoint is registered as an ASP.NET Core endpoint at startup and supports hot-reload when the model changes. |
||||
|
|
||||
|
## Defining Endpoints |
||||
|
|
||||
|
Add endpoints to the `endpoints` array in `model.json`: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"endpoints": [ |
||||
|
{ |
||||
|
"name": "GetProductStats", |
||||
|
"route": "/api/custom/products/stats", |
||||
|
"method": "GET", |
||||
|
"description": "Get product statistics", |
||||
|
"requireAuthentication": false, |
||||
|
"javascript": "var count = await db.count('LowCodeDemo.Products.Product');\nreturn ok({ totalProducts: count });" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Endpoint Descriptor |
||||
|
|
||||
|
| Field | Type | Default | Description | |
||||
|
|-------|------|---------|-------------| |
||||
|
| `name` | string | **Required** | Unique endpoint name | |
||||
|
| `route` | string | **Required** | URL route pattern (supports `{parameters}`) | |
||||
|
| `method` | string | `"GET"` | HTTP method: `GET`, `POST`, `PUT`, `DELETE` | |
||||
|
| `javascript` | string | **Required** | JavaScript handler code | |
||||
|
| `description` | string | null | Description for documentation | |
||||
|
| `requireAuthentication` | bool | `true` | Require authenticated user | |
||||
|
| `requiredPermissions` | string[] | null | Required permission names | |
||||
|
|
||||
|
## Route Parameters |
||||
|
|
||||
|
Use `{paramName}` syntax in the route. Access values via the `route` object: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "GetProductById", |
||||
|
"route": "/api/custom/products/{id}", |
||||
|
"method": "GET", |
||||
|
"javascript": "var product = await db.get('LowCodeDemo.Products.Product', route.id);\nif (!product) { return notFound('Product not found'); }\nreturn ok({ id: product.Id, name: product.Name, price: product.Price });" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## JavaScript Context |
||||
|
|
||||
|
Inside custom endpoint scripts, you have access to: |
||||
|
|
||||
|
### Request Context |
||||
|
|
||||
|
| Variable | Description | |
||||
|
|----------|-------------| |
||||
|
| `request` | Full request object | |
||||
|
| `route` | Route parameter values (e.g., `route.id`) | |
||||
|
| `params` | Alias for route parameters | |
||||
|
| `query` | Query string parameters (e.g., `query.q`, `query.page`) | |
||||
|
| `body` | Request body (for POST/PUT) | |
||||
|
| `headers` | Request headers | |
||||
|
| `user` | Current user (same as `context.currentUser` in [Interceptors](interceptors.md)) | |
||||
|
| `email` | Email sender (same as `context.emailSender` in [Interceptors](interceptors.md)) | |
||||
|
|
||||
|
### Response Helpers |
||||
|
|
||||
|
| Function | HTTP Status | Description | |
||||
|
|----------|-------------|-------------| |
||||
|
| `ok(data)` | 200 | Success response with data | |
||||
|
| `created(data)` | 201 | Created response with data | |
||||
|
| `noContent()` | 204 | No content response | |
||||
|
| `badRequest(message)` | 400 | Bad request response | |
||||
|
| `unauthorized(message)` | 401 | Unauthorized response | |
||||
|
| `forbidden(message)` | 403 | Forbidden response | |
||||
|
| `notFound(message)` | 404 | Not found response | |
||||
|
| `error(message)` | 500 | Internal server error response | |
||||
|
| `response(statusCode, data, error)` | Custom | Custom status code response | |
||||
|
|
||||
|
### Logging |
||||
|
|
||||
|
| Function | Description | |
||||
|
|----------|-------------| |
||||
|
| `log(message)` | Log an informational message | |
||||
|
| `logWarning(message)` | Log a warning message | |
||||
|
| `logError(message)` | Log an error message | |
||||
|
|
||||
|
### Database API |
||||
|
|
||||
|
The full [Scripting API](scripting-api.md) (`db` object) is available for querying and mutating data. |
||||
|
|
||||
|
## Examples |
||||
|
|
||||
|
### Get Statistics |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "GetProductStats", |
||||
|
"route": "/api/custom/products/stats", |
||||
|
"method": "GET", |
||||
|
"requireAuthentication": false, |
||||
|
"javascript": "var totalCount = await db.count('LowCodeDemo.Products.Product');\nvar avgPrice = totalCount > 0 ? await db.query('LowCodeDemo.Products.Product').average(p => p.Price) : 0;\nreturn ok({ totalProducts: totalCount, averagePrice: avgPrice });" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Search with Query Parameters |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "SearchCustomers", |
||||
|
"route": "/api/custom/customers/search", |
||||
|
"method": "GET", |
||||
|
"requireAuthentication": true, |
||||
|
"javascript": "var searchTerm = query.q || '';\nvar customers = await db.query('LowCodeDemo.Customers.Customer')\n .where(c => c.Name.toLowerCase().includes(searchTerm.toLowerCase()))\n .take(10)\n .toList();\nreturn ok(customers.map(c => ({ id: c.Id, name: c.Name, email: c.EmailAddress })));" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Dashboard Summary |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "GetDashboardSummary", |
||||
|
"route": "/api/custom/dashboard", |
||||
|
"method": "GET", |
||||
|
"requireAuthentication": true, |
||||
|
"javascript": "var productCount = await db.count('LowCodeDemo.Products.Product');\nvar customerCount = await db.count('LowCodeDemo.Customers.Customer');\nvar orderCount = await db.count('LowCodeDemo.Orders.Order');\nreturn ok({ products: productCount, customers: customerCount, orders: orderCount, user: user.isAuthenticated ? user.userName : 'Anonymous' });" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Authentication and Authorization |
||||
|
|
||||
|
| Setting | Behavior | |
||||
|
|---------|----------| |
||||
|
| `requireAuthentication: false` | Endpoint is publicly accessible | |
||||
|
| `requireAuthentication: true` | User must be authenticated | |
||||
|
| `requiredPermissions: ["MyApp.Products"]` | User must have the specified permissions | |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [Scripting API](scripting-api.md) |
||||
|
* [Interceptors](interceptors.md) |
||||
|
* [model.json Structure](model-json.md) |
||||
@ -0,0 +1,572 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Define dynamic entities using C# attributes and configure them with the Fluent API in the ABP Low-Code System. The primary way to build auto-generated admin panels." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Attributes & Fluent API |
||||
|
|
||||
|
C# Attributes and the Fluent API are the **recommended way** to define dynamic entities. They provide compile-time checking, IntelliSense, refactoring support, and keep your entity definitions close to your domain code. |
||||
|
|
||||
|
## Quick Start |
||||
|
|
||||
|
### Step 1: Define an Entity |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Products")] |
||||
|
public class Product : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicPropertyUnique] |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
[DynamicPropertyUI(DisplayName = "Unit Price")] |
||||
|
public decimal Price { get; set; } |
||||
|
|
||||
|
public int StockCount { get; set; } |
||||
|
|
||||
|
public DateTime? ReleaseDate { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### Step 2: Add Migration and Run |
||||
|
|
||||
|
```bash |
||||
|
dotnet ef migrations add Added_Product |
||||
|
dotnet ef database update |
||||
|
``` |
||||
|
|
||||
|
You now have a complete Product management page with data grid, create/edit modals, search, sorting, and pagination. |
||||
|
|
||||
|
### Step 3: Add Relationships |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Orders")] |
||||
|
public class Order : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
||||
|
public Guid CustomerId { get; set; } |
||||
|
|
||||
|
public decimal TotalAmount { get; set; } |
||||
|
public bool IsDelivered { get; set; } |
||||
|
} |
||||
|
|
||||
|
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
||||
|
public class OrderLine : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
||||
|
public Guid ProductId { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
public decimal Amount { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
The `Order` page now has a foreign key dropdown for Customer, and `OrderLine` is managed as a nested child inside the Order detail modal. |
||||
|
|
||||
|
## Three-Layer Configuration System |
||||
|
|
||||
|
The Low-Code System uses a layered configuration model. From lowest to highest priority: |
||||
|
|
||||
|
1. **Code Layer** — C# classes with `[DynamicEntity]` and other attributes |
||||
|
2. **JSON Layer** — `model.json` file (see [model.json Structure](model-json.md)) |
||||
|
3. **Fluent Layer** — `AbpDynamicEntityConfig.EntityConfigurations` |
||||
|
|
||||
|
A `DefaultLayer` runs last to fill in any missing values with conventions. |
||||
|
|
||||
|
> When the same entity or property is configured in multiple layers, the higher-priority layer wins. |
||||
|
|
||||
|
## C# Attributes Reference |
||||
|
|
||||
|
### `[DynamicEntity]` |
||||
|
|
||||
|
Marks a class as a dynamic entity. The entity name is derived from the class namespace and name. |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
public class Product : DynamicEntityBase |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
public decimal Price { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Use the `Parent` property for parent-child (master-detail) relationships: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
||||
|
public class OrderLine : DynamicEntityBase |
||||
|
{ |
||||
|
public Guid ProductId { get; set; } |
||||
|
public int Quantity { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### `[DynamicEntityUI]` |
||||
|
|
||||
|
Configures entity-level UI. Entities with `PageTitle` get a menu item and a dedicated page: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Product Management")] |
||||
|
public class Product : DynamicEntityBase |
||||
|
{ |
||||
|
// ... |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### `[DynamicForeignKey]` |
||||
|
|
||||
|
Defines a foreign key relationship on a `Guid` property: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
||||
|
public Guid CustomerId { get; set; } |
||||
|
```` |
||||
|
|
||||
|
| Parameter | Description | |
||||
|
|-----------|-------------| |
||||
|
| `entityName` | Full name of the target entity — can be a **dynamic entity** (e.g., `"MyApp.Customers.Customer"`) or a **[reference entity](reference-entities.md)** (e.g., `"Volo.Abp.Identity.IdentityUser"`) | |
||||
|
| `displayPropertyName` | Property to show in lookups | |
||||
|
| `access` | `ForeignAccess.None`, `ForeignAccess.View`, or `ForeignAccess.Edit` (see [Foreign Access](foreign-access.md)) | |
||||
|
|
||||
|
### `[DynamicPropertyUI]` |
||||
|
|
||||
|
Controls property visibility and behavior in the UI: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicPropertyUI( |
||||
|
DisplayName = "Registration Number", |
||||
|
IsAvailableOnListing = true, |
||||
|
IsAvailableOnDataTableFiltering = true, |
||||
|
CreationFormAvailability = EntityPropertyUIFormAvailability.Hidden, |
||||
|
EditingFormAvailability = EntityPropertyUIFormAvailability.NotAvailable, |
||||
|
QuickLookOrder = 100 |
||||
|
)] |
||||
|
public string RegistrationNumber { get; set; } |
||||
|
```` |
||||
|
|
||||
|
| Property | Type | Default | Description | |
||||
|
|----------|------|---------|-------------| |
||||
|
| `DisplayName` | string | null | Custom label for the property | |
||||
|
| `IsAvailableOnListing` | bool | `true` | Show in data grid | |
||||
|
| `IsAvailableOnDataTableFiltering` | bool | `true` | Show in filter panel | |
||||
|
| `CreationFormAvailability` | enum | `Available` | Visibility on create form | |
||||
|
| `EditingFormAvailability` | enum | `Available` | Visibility on edit form | |
||||
|
| `QuickLookOrder` | int | `-2` | Order in quick-look panel | |
||||
|
|
||||
|
The quick-look panel shows a summary of the selected record: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### `[DynamicPropertyServerOnly]` |
||||
|
|
||||
|
Hides a property from API clients entirely. It is stored in the database but never returned to the client: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicPropertyServerOnly] |
||||
|
public string InternalNotes { get; set; } |
||||
|
```` |
||||
|
|
||||
|
### `[DynamicPropertySetByClients]` |
||||
|
|
||||
|
Controls whether clients can set this property value. Useful for computed or server-assigned fields: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicPropertySetByClients(false)] |
||||
|
public string RegistrationNumber { get; set; } |
||||
|
```` |
||||
|
|
||||
|
### `[DynamicPropertyUnique]` |
||||
|
|
||||
|
Marks a property as requiring unique values across all records: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicPropertyUnique] |
||||
|
public string ProductCode { get; set; } |
||||
|
```` |
||||
|
|
||||
|
### `[DynamicEntityCommandInterceptor]` |
||||
|
|
||||
|
Defines JavaScript interceptors on a class for CRUD lifecycle hooks: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityCommandInterceptor( |
||||
|
"Create", |
||||
|
InterceptorType.Pre, |
||||
|
"if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
||||
|
)] |
||||
|
[DynamicEntityCommandInterceptor( |
||||
|
"Delete", |
||||
|
InterceptorType.Post, |
||||
|
"context.log('Deleted: ' + context.commandArgs.entityId);" |
||||
|
)] |
||||
|
public class Organization : DynamicEntityBase |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> The `Name` parameter must be one of: `"Create"`, `"Update"`, or `"Delete"`. The `InterceptorType` can be `Pre`, `Post`, or `Replace`. When `Replace` is used, the default DB operation is skipped entirely and only the JavaScript handler runs. **`Replace-Create` must return the new entity's Id** (e.g. `return result.Id;` after `db.insert`). Multiple interceptors can be added to the same class (`AllowMultiple = true`). |
||||
|
|
||||
|
See [Interceptors](interceptors.md) for the full JavaScript context API. |
||||
|
|
||||
|
### `[DynamicEnum]` |
||||
|
|
||||
|
Marks an enum for use in dynamic entity properties: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEnum] |
||||
|
public enum OrganizationType |
||||
|
{ |
||||
|
Corporate = 0, |
||||
|
Enterprise = 1, |
||||
|
Startup = 2, |
||||
|
Consulting = 3 |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Reference in an entity: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Organizations")] |
||||
|
public class Organization : DynamicEntityBase |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
public OrganizationType OrganizationType { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### Enum Localization |
||||
|
|
||||
|
Enum values can be localized using ABP's localization system. Add localization keys in the format `Enum:{EnumTypeName}.{ValueName}` to your localization JSON files: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"culture": "en", |
||||
|
"texts": { |
||||
|
"Enum:OrganizationType.Corporate": "Corporate", |
||||
|
"Enum:OrganizationType.Enterprise": "Enterprise", |
||||
|
"Enum:OrganizationType.Startup": "Startup", |
||||
|
"Enum:OrganizationType.Consulting": "Consulting" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
The Blazor UI automatically uses these localization keys for enum dropdowns and display values. If no localization key is found, the enum member name is used as-is. |
||||
|
|
||||
|
## Fluent API |
||||
|
|
||||
|
The Fluent API has the **highest priority** in the configuration system. Use `AbpDynamicEntityConfig.EntityConfigurations` to override any attribute or JSON setting programmatically. |
||||
|
|
||||
|
### Basic Usage |
||||
|
|
||||
|
Configure in your Low-Code Initializer (e.g. `MyAppLowCodeInitializer`): |
||||
|
|
||||
|
````csharp |
||||
|
public static class MyAppLowCodeInitializer |
||||
|
{ |
||||
|
private static readonly AsyncOneTimeRunner Runner = new(); |
||||
|
|
||||
|
public static async Task InitializeAsync() |
||||
|
{ |
||||
|
await Runner.RunAsync(async () => |
||||
|
{ |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
||||
|
"MyApp.Products.Product", |
||||
|
entity => |
||||
|
{ |
||||
|
entity.DefaultDisplayPropertyName = "Name"; |
||||
|
|
||||
|
var priceProperty = entity.AddOrGetProperty("Price"); |
||||
|
priceProperty.AsRequired(); |
||||
|
priceProperty.UI = new EntityPropertyUIDescriptor |
||||
|
{ |
||||
|
DisplayName = "Unit Price", |
||||
|
CreationFormAvailability = EntityPropertyUIFormAvailability.Available |
||||
|
}; |
||||
|
|
||||
|
entity.AddOrGetProperty("InternalNotes").AsServerOnly(); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
await DynamicModelManager.Instance.InitializeAsync(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
You can also use the generic overload with a type parameter: |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure<Product>(entity => |
||||
|
{ |
||||
|
entity.DefaultDisplayPropertyName = "Name"; |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
### Entity Configuration |
||||
|
|
||||
|
The `Configure` method provides an `EntityDescriptor` instance. You can set its properties directly: |
||||
|
|
||||
|
| Property / Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `DefaultDisplayPropertyName` | Set the display property for lookups | |
||||
|
| `Parent` | Set parent entity name for nesting | |
||||
|
| `UI` | Set entity-level UI (`EntityUIDescriptor` with `PageTitle`) | |
||||
|
| `AddOrGetProperty(name)` | Get or create a property descriptor for configuration | |
||||
|
| `FindProperty(name)` | Find a property descriptor by name (returns `null` if not found) | |
||||
|
| `GetProperty(name)` | Get a property descriptor by name (throws if not found) | |
||||
|
| `Interceptors` | List of `CommandInterceptorDescriptor` — add interceptors directly | |
||||
|
|
||||
|
### Property Configuration |
||||
|
|
||||
|
`AddOrGetProperty` returns an `EntityPropertyDescriptor`. Configure it using direct property assignment and extension methods: |
||||
|
|
||||
|
| Property / Extension Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `.AsRequired(bool)` | Mark as required (extension method, returns the descriptor for chaining) | |
||||
|
| `.AsServerOnly(bool)` | Hide from clients (extension method, returns the descriptor for chaining) | |
||||
|
| `.MapToDbField(bool)` | Control if property is stored in DB (extension method, returns the descriptor for chaining) | |
||||
|
| `.IsUnique` | Set to `true` to mark as unique | |
||||
|
| `.AllowSetByClients` | Set to `false` to prevent client writes | |
||||
|
| `.ForeignKey` | Set a `ForeignKeyDescriptor` to configure foreign key relationship | |
||||
|
| `.UI` | Set an `EntityPropertyUIDescriptor` to configure property UI | |
||||
|
|
||||
|
### Chaining Extension Methods |
||||
|
|
||||
|
The extension methods `AsRequired()`, `AsServerOnly()`, and `MapToDbField()` return the property descriptor, enabling fluent chaining: |
||||
|
|
||||
|
````csharp |
||||
|
entity.AddOrGetProperty("InternalNotes") |
||||
|
.AsServerOnly() |
||||
|
.AsRequired() |
||||
|
.MapToDbField(); |
||||
|
```` |
||||
|
|
||||
|
### Configuring Foreign Keys |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
||||
|
"MyApp.Orders.Order", |
||||
|
entity => |
||||
|
{ |
||||
|
var customerIdProperty = entity.AddOrGetProperty("CustomerId"); |
||||
|
customerIdProperty.ForeignKey = new ForeignKeyDescriptor |
||||
|
{ |
||||
|
EntityName = "MyApp.Customers.Customer", |
||||
|
DisplayPropertyName = "Name", |
||||
|
Access = ForeignAccess.Edit |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
```` |
||||
|
|
||||
|
### Adding Interceptors |
||||
|
|
||||
|
````csharp |
||||
|
entity.Interceptors.Add(new CommandInterceptorDescriptor("Create") |
||||
|
{ |
||||
|
Type = InterceptorType.Pre, |
||||
|
Javascript = "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
## Assembly Registration |
||||
|
|
||||
|
Register assemblies containing `[DynamicEntity]` classes in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer): |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo( |
||||
|
typeof(MyDomainModule).Assembly, |
||||
|
rootNamespace: "MyApp", |
||||
|
projectRootPath: sourcePath // For model.json hot-reload |
||||
|
) |
||||
|
); |
||||
|
```` |
||||
|
|
||||
|
| Parameter | Description | |
||||
|
|-----------|-------------| |
||||
|
| `assembly` | The assembly containing `[DynamicEntity]` classes and/or `model.json` | |
||||
|
| `rootNamespace` | Root namespace for the assembly (used for embedded resource lookup) | |
||||
|
| `projectRootPath` | Path to the Domain project source folder (enables `model.json` hot-reload in development) | |
||||
|
|
||||
|
You can also register entity types directly: |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.DynamicEntityTypes.Add(typeof(Product)); |
||||
|
AbpDynamicEntityConfig.DynamicEnumTypes.Add(typeof(OrganizationType)); |
||||
|
```` |
||||
|
|
||||
|
## Combining with model.json |
||||
|
|
||||
|
Attributes and model.json work together seamlessly. A common pattern: |
||||
|
|
||||
|
1. **Define core entities** with C# attributes (compile-time safety) |
||||
|
2. **Add additional entities** via model.json (no recompilation needed) |
||||
|
3. **Fine-tune configuration** with Fluent API (overrides everything) |
||||
|
|
||||
|
The three-layer system merges all definitions: |
||||
|
|
||||
|
``` |
||||
|
Fluent API (highest) > JSON (model.json) > Code (Attributes) > Defaults (lowest) |
||||
|
``` |
||||
|
|
||||
|
For example, if an attribute sets `[DynamicPropertyUnique]` and model.json sets `"isUnique": false`, the JSON value wins because JSON layer has higher priority than Code layer. |
||||
|
|
||||
|
## End-to-End Example |
||||
|
|
||||
|
A complete e-commerce-style entity setup: |
||||
|
|
||||
|
````csharp |
||||
|
// Enum |
||||
|
[DynamicEnum] |
||||
|
public enum OrderStatus |
||||
|
{ |
||||
|
Pending = 0, |
||||
|
Processing = 1, |
||||
|
Shipped = 2, |
||||
|
Delivered = 3 |
||||
|
} |
||||
|
|
||||
|
// Customer entity |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Customers")] |
||||
|
public class Customer : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicPropertyUnique] |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
[DynamicPropertyUI(DisplayName = "Phone Number", QuickLookOrder = 100)] |
||||
|
public string Telephone { get; set; } |
||||
|
|
||||
|
[DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")] |
||||
|
public Guid? UserId { get; set; } |
||||
|
|
||||
|
[DynamicPropertyServerOnly] |
||||
|
public string InternalNotes { get; set; } |
||||
|
} |
||||
|
|
||||
|
// Product entity |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Products")] |
||||
|
public class Product : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicPropertyUnique] |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
public decimal Price { get; set; } |
||||
|
public int StockCount { get; set; } |
||||
|
} |
||||
|
|
||||
|
// Order entity with child OrderLine |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Orders")] |
||||
|
[DynamicEntityCommandInterceptor( |
||||
|
"Update", |
||||
|
InterceptorType.Pre, |
||||
|
@"if(context.commandArgs.data['IsDelivered']) { |
||||
|
if(!context.currentUser.roles.includes('admin')) { |
||||
|
globalError = 'Only admins can mark as delivered!'; |
||||
|
} |
||||
|
}" |
||||
|
)] |
||||
|
public class Order : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
||||
|
public Guid CustomerId { get; set; } |
||||
|
|
||||
|
public decimal TotalAmount { get; set; } |
||||
|
public bool IsDelivered { get; set; } |
||||
|
public OrderStatus Status { get; set; } |
||||
|
} |
||||
|
|
||||
|
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
||||
|
public class OrderLine : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
||||
|
public Guid ProductId { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
public decimal Amount { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Register everything in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer): |
||||
|
|
||||
|
````csharp |
||||
|
public static class MyAppLowCodeInitializer |
||||
|
{ |
||||
|
private static readonly AsyncOneTimeRunner Runner = new(); |
||||
|
|
||||
|
public static async Task InitializeAsync() |
||||
|
{ |
||||
|
await Runner.RunAsync(async () => |
||||
|
{ |
||||
|
// Reference existing ABP entities |
||||
|
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>("UserName"); |
||||
|
|
||||
|
// Register assembly |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo(typeof(MyDomainModule).Assembly) |
||||
|
); |
||||
|
|
||||
|
// Initialize |
||||
|
await DynamicModelManager.Instance.InitializeAsync(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Configure your DbContext to implement `IDbContextWithDynamicEntities`: |
||||
|
|
||||
|
````csharp |
||||
|
public class MyAppDbContext : AbpDbContext<MyAppDbContext>, IDbContextWithDynamicEntities |
||||
|
{ |
||||
|
// ... constructors and DbSets ... |
||||
|
|
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
builder.ConfigureDynamicEntities(); |
||||
|
base.OnModelCreating(builder); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Configure your DbContextFactory for EF Core CLI commands: |
||||
|
|
||||
|
````csharp |
||||
|
public class MyAppDbContextFactory : IDesignTimeDbContextFactory<MyAppDbContext> |
||||
|
{ |
||||
|
public MyAppDbContext CreateDbContext(string[] args) |
||||
|
{ |
||||
|
var configuration = BuildConfiguration(); |
||||
|
|
||||
|
MyAppEfCoreEntityExtensionMappings.Configure(); |
||||
|
|
||||
|
// ----- Ensure Low-Code system is initialized before running migrations --- |
||||
|
LowCodeEfCoreTypeBuilderExtensions.Configure(); |
||||
|
AsyncHelper.RunSync(MyAppLowCodeInitializer.InitializeAsync); |
||||
|
// ------------------------------- |
||||
|
|
||||
|
var builder = new DbContextOptionsBuilder<MyAppDbContext>() |
||||
|
.UseSqlServer(configuration.GetConnectionString("Default")); |
||||
|
|
||||
|
return new MyAppDbContext(builder.Options); |
||||
|
} |
||||
|
|
||||
|
// ... BuildConfiguration method ... |
||||
|
} |
||||
|
|
||||
|
This gives you four auto-generated pages (Customers, Products, Orders with nested OrderLines), complete with permissions, menu items, foreign key lookups, and interceptor-based business rules. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Reference Entities](reference-entities.md) |
||||
|
* [Interceptors](interceptors.md) |
||||
@ -0,0 +1,148 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Control access to related entities through foreign key relationships using Foreign Access in the ABP Low-Code System." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Foreign Access |
||||
|
|
||||
|
Foreign Access controls how related **dynamic entities** can be accessed through foreign key relationships. It determines whether users can view or manage related data directly from the **target entity's** UI. |
||||
|
|
||||
|
> **Important:** Foreign Access only works between **dynamic entities**. It does not apply to [reference entities](reference-entities.md) because they are read-only and don't have UI pages. |
||||
|
|
||||
|
## Access Levels |
||||
|
|
||||
|
The `ForeignAccess` enum defines three levels: |
||||
|
|
||||
|
| Level | Value | Description | |
||||
|
|-------|-------|-------------| |
||||
|
| `None` | 0 | No access from the target entity side. The relationship exists only for lookups. | |
||||
|
| `View` | 1 | Read-only access. Users can view related records from the target entity's action menu. | |
||||
|
| `Edit` | 2 | Full CRUD access. Users can create, update, and delete related records from the target entity's action menu. | |
||||
|
|
||||
|
## Configuring with Attributes |
||||
|
|
||||
|
Use the third parameter of `[DynamicForeignKey]`: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
public class Order |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
||||
|
public Guid CustomerId { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
## Configuring with Fluent API |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
||||
|
"MyApp.Orders.Order", |
||||
|
entity => |
||||
|
{ |
||||
|
var customerIdProperty = entity.AddOrGetProperty("CustomerId"); |
||||
|
customerIdProperty.ForeignKey = new ForeignKeyDescriptor |
||||
|
{ |
||||
|
EntityName = "MyApp.Customers.Customer", |
||||
|
DisplayPropertyName = "Name", |
||||
|
Access = ForeignAccess.Edit |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
```` |
||||
|
|
||||
|
## Configuring in model.json |
||||
|
|
||||
|
Set the `access` field on a foreign key property: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "CustomerId", |
||||
|
"foreignKey": { |
||||
|
"entityName": "LowCodeDemo.Customers.Customer", |
||||
|
"displayPropertyName": "Name", |
||||
|
"access": "edit" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Examples from the Demo Application |
||||
|
|
||||
|
**Edit access** — Orders can be managed from the Customer page: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "LowCodeDemo.Orders.Order", |
||||
|
"properties": [ |
||||
|
{ |
||||
|
"name": "CustomerId", |
||||
|
"foreignKey": { |
||||
|
"entityName": "LowCodeDemo.Customers.Customer", |
||||
|
"access": "edit" |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**View access** — Visited countries are viewable from the Country page: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "LowCodeDemo.Customers.VisitedCountry", |
||||
|
"parent": "LowCodeDemo.Customers.Customer", |
||||
|
"properties": [ |
||||
|
{ |
||||
|
"name": "CountryId", |
||||
|
"foreignKey": { |
||||
|
"entityName": "LowCodeDemo.Countries.Country", |
||||
|
"access": "view" |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## UI Behavior |
||||
|
|
||||
|
When foreign access is configured between two **dynamic entities**: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### `ForeignAccess.View` |
||||
|
|
||||
|
An **action menu item** appears on the target entity's data grid row (e.g., a "Visited Countries" item on the Country row). Clicking it opens a read-only modal showing related records. |
||||
|
|
||||
|
### `ForeignAccess.Edit` |
||||
|
|
||||
|
An **action menu item** appears on the target entity's data grid row (e.g., an "Orders" item on the Customer row). Clicking it opens a fully functional CRUD modal where users can create, edit, and delete related records. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### `ForeignAccess.None` |
||||
|
|
||||
|
No action menu item is added. The foreign key exists only for data integrity and lookup display. |
||||
|
|
||||
|
## Permission Control |
||||
|
|
||||
|
Foreign access actions respect the **entity permissions** of the source entity (the entity with the foreign key). For example, if a user does not have the `Delete` permission for `Order`, the delete button will not appear in the foreign access modal, even if the access level is `Edit`. |
||||
|
|
||||
|
## How It Works |
||||
|
|
||||
|
The `ForeignAccessRelation` class stores the relationship metadata: |
||||
|
|
||||
|
* **Source entity** — the dynamic entity with the foreign key (e.g., `Order`) |
||||
|
* **Target entity** — the dynamic entity being referenced (e.g., `Customer`) |
||||
|
* **Foreign key property** — the property name (e.g., `CustomerId`) |
||||
|
* **Access level** — `None`, `View`, or `Edit` |
||||
|
|
||||
|
The `DynamicEntityAppService` checks these relations when building entity actions and filtering data. |
||||
|
|
||||
|
> **Terminology:** In foreign access context, "target entity" refers to the entity whose UI shows the action menu (the entity being pointed to by the foreign key). This is different from "reference entity" which specifically means an existing C# entity registered for read-only access. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Reference Entities](reference-entities.md) |
||||
|
* [Attributes & Fluent API](fluent-api.md) |
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@ -0,0 +1,365 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "ABP Low-Code System: Build admin panels with auto-generated CRUD UI, APIs, and permissions using C# attributes and Fluent API. No boilerplate code needed." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Low-Code System |
||||
|
|
||||
|
> You must have an ABP Team or a higher license to use this module. |
||||
|
|
||||
|
The ABP Low-Code System allows you to define entities using C# attributes or Fluent API and automatically generates: |
||||
|
|
||||
|
* **Database tables** (via EF Core migrations) |
||||
|
* **CRUD REST APIs** (Get, GetList, Create, Update, Delete) |
||||
|
* **Permissions** (View, Create, Update, Delete per entity) |
||||
|
* **Menu items** (auto-added to the admin sidebar) |
||||
|
* **Full Blazor UI** (data grid, create/edit modals, filters, foreign key lookups) |
||||
|
|
||||
|
No need to write DTOs, application services, repositories, or UI pages manually. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## Why Low-Code? |
||||
|
|
||||
|
Traditionally, adding a new entity with full CRUD functionality to an ABP application requires: |
||||
|
|
||||
|
* Entity class in Domain |
||||
|
* DbContext configuration in EF Core |
||||
|
* DTOs in Application.Contracts |
||||
|
* AppService in Application |
||||
|
* Controller in HttpApi |
||||
|
* Razor/Blazor pages in UI |
||||
|
* Permissions, menu items, localization |
||||
|
|
||||
|
**With Low-Code, a single C# class replaces all of the above:** |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity(DefaultDisplayPropertyName = "Name")] |
||||
|
[DynamicEntityUI(PageTitle = "Products")] |
||||
|
public class Product : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicPropertyUnique] |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
[DynamicPropertyUI(DisplayName = "Unit Price")] |
||||
|
public decimal Price { get; set; } |
||||
|
|
||||
|
public int StockCount { get; set; } |
||||
|
|
||||
|
[DynamicForeignKey("MyApp.Categories.Category", "Name")] |
||||
|
public Guid? CategoryId { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Run `dotnet ef migrations add Added_Product` and start your application. You get a complete Product management page with search, filtering, sorting, pagination, create/edit forms, and foreign key dropdown — all auto-generated. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## Getting Started |
||||
|
|
||||
|
### 1. Create a Low-Code Initializer |
||||
|
|
||||
|
Create a static initializer class in your Domain project's `_Dynamic` folder that registers your assembly and calls `DynamicModelManager.Instance.InitializeAsync()`: |
||||
|
|
||||
|
````csharp |
||||
|
using Volo.Abp.Identity; |
||||
|
using Volo.Abp.LowCode.Configuration; |
||||
|
using Volo.Abp.LowCode.Modeling; |
||||
|
using Volo.Abp.Threading; |
||||
|
|
||||
|
namespace MyApp._Dynamic; |
||||
|
|
||||
|
public static class MyAppLowCodeInitializer |
||||
|
{ |
||||
|
private static readonly AsyncOneTimeRunner Runner = new(); |
||||
|
|
||||
|
public static async Task InitializeAsync() |
||||
|
{ |
||||
|
await Runner.RunAsync(async () => |
||||
|
{ |
||||
|
// Register reference entities (optional — for linking to existing C# entities) |
||||
|
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>( |
||||
|
nameof(IdentityUser.UserName), |
||||
|
nameof(IdentityUser.Email) |
||||
|
); |
||||
|
|
||||
|
// Register assemblies containing [DynamicEntity] classes and model.json |
||||
|
var sourcePath = ResolveDomainSourcePath(); |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo( |
||||
|
typeof(MyAppDomainModule).Assembly, |
||||
|
rootNamespace: "MyApp", |
||||
|
projectRootPath: sourcePath // Required for model.json hot-reload in development |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
// Fluent API configurations (optional — highest priority) |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure("MyApp.Products.Product", entity => |
||||
|
{ |
||||
|
entity.AddOrGetProperty("InternalNotes").AsServerOnly(); |
||||
|
}); |
||||
|
|
||||
|
// Initialize the dynamic model manager |
||||
|
await DynamicModelManager.Instance.InitializeAsync(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private static string ResolveDomainSourcePath() |
||||
|
{ |
||||
|
// Traverse up from bin folder to find the Domain project source |
||||
|
var baseDir = AppContext.BaseDirectory; |
||||
|
var current = new DirectoryInfo(baseDir); |
||||
|
|
||||
|
for (int i = 0; i < 10 && current != null; i++) |
||||
|
{ |
||||
|
var candidate = Path.Combine(current.FullName, "src", "MyApp.Domain"); |
||||
|
if (Directory.Exists(Path.Combine(candidate, "_Dynamic"))) |
||||
|
{ |
||||
|
return candidate; |
||||
|
} |
||||
|
current = current.Parent; |
||||
|
} |
||||
|
|
||||
|
// Fallback for production (embedded resource will be used instead) |
||||
|
return string.Empty; |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> The `projectRootPath` parameter enables hot-reload of `model.json` during development. When the path is empty or the file doesn't exist, the module falls back to reading `model.json` as an embedded resource. |
||||
|
|
||||
|
### 2. Call the Initializer in Program.cs |
||||
|
|
||||
|
The initializer must be called **before** the application starts. Add it to `Program.cs`: |
||||
|
|
||||
|
````csharp |
||||
|
public static async Task<int> Main(string[] args) |
||||
|
{ |
||||
|
// Initialize Low-Code before building the application |
||||
|
await MyAppLowCodeInitializer.InitializeAsync(); |
||||
|
|
||||
|
var builder = WebApplication.CreateBuilder(args); |
||||
|
// ... rest of your startup code |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> **Important:** The initializer must also be called in your `DbMigrator` project and any other entry points (AuthServer, HttpApi.Host, etc.) that use dynamic entities. This ensures EF Core migrations can discover the entity schema. |
||||
|
|
||||
|
### 3. Configure DbContext |
||||
|
|
||||
|
Call `ConfigureDynamicEntities()` in your `DbContext`: |
||||
|
|
||||
|
````csharp |
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
builder.ConfigureDynamicEntities(); |
||||
|
base.OnModelCreating(builder); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### 3. Define Your First Entity |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Customers")] |
||||
|
public class Customer : DynamicEntityBase |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
[DynamicPropertyUI(DisplayName = "Phone Number")] |
||||
|
public string Telephone { get; set; } |
||||
|
|
||||
|
[DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")] |
||||
|
public Guid? UserId { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### 4. Add Migration and Run |
||||
|
|
||||
|
```bash |
||||
|
dotnet ef migrations add Added_Customer |
||||
|
dotnet ef database update |
||||
|
``` |
||||
|
|
||||
|
Start your application — the Customer page is ready. |
||||
|
|
||||
|
## Two Ways to Define Entities |
||||
|
|
||||
|
### C# Attributes (Recommended) |
||||
|
|
||||
|
Define entities as C# classes with attributes. You get compile-time checking, IntelliSense, and refactoring support: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Orders")] |
||||
|
public class Order : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
||||
|
public Guid CustomerId { get; set; } |
||||
|
|
||||
|
public decimal TotalAmount { get; set; } |
||||
|
public bool IsDelivered { get; set; } |
||||
|
} |
||||
|
|
||||
|
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
||||
|
public class OrderLine : DynamicEntityBase |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
||||
|
public Guid ProductId { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
public decimal Amount { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
See [Attributes & Fluent API](fluent-api.md) for the full attribute reference. |
||||
|
|
||||
|
### model.json (Declarative) |
||||
|
|
||||
|
Alternatively, define entities in a JSON file without writing C# classes: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"entities": [ |
||||
|
{ |
||||
|
"name": "MyApp.Customers.Customer", |
||||
|
"displayProperty": "Name", |
||||
|
"properties": [ |
||||
|
{ "name": "Name", "isRequired": true }, |
||||
|
{ "name": "Telephone", "ui": { "displayName": "Phone Number" } } |
||||
|
], |
||||
|
"ui": { "pageTitle": "Customers" } |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
See [model.json Structure](model-json.md) for the full specification. |
||||
|
|
||||
|
> Both approaches can be combined. The [three-layer configuration system](fluent-api.md#three-layer-configuration-system) merges Attributes, JSON, and Fluent API with clear priority rules. |
||||
|
|
||||
|
## Key Features |
||||
|
|
||||
|
| Feature | Description | Documentation | |
||||
|
|---------|-------------|---------------| |
||||
|
| **Attributes & Fluent API** | Define dynamic entities with C# attributes and configure programmatically | [Attributes & Fluent API](fluent-api.md) | |
||||
|
| **model.json** | Declarative dynamic entity definitions in JSON | [model.json Structure](model-json.md) | |
||||
|
| **Reference Entities** | Read-only access to existing C# entities (e.g., `IdentityUser`) for foreign key lookups | [Reference Entities](reference-entities.md) | |
||||
|
| **Interceptors** | Pre/Post hooks for Create, Update, Delete with JavaScript | [Interceptors](interceptors.md) | |
||||
|
| **Scripting API** | Server-side JavaScript for database queries and CRUD | [Scripting API](scripting-api.md) | |
||||
|
| **Custom Endpoints** | REST APIs with JavaScript handlers | [Custom Endpoints](custom-endpoints.md) | |
||||
|
| **Foreign Access** | View/Edit related dynamic entities from the target entity's UI | [Foreign Access](foreign-access.md) | |
||||
|
| **Export** | Export dynamic entity data to Excel (XLSX) or CSV | See below | |
||||
|
|
||||
|
## Export (Excel / CSV) |
||||
|
|
||||
|
The Low-Code System provides built-in export functionality for all dynamic entities. Users can export filtered data to **Excel (XLSX)** or **CSV** directly from the Blazor UI. |
||||
|
|
||||
|
### How It Works |
||||
|
|
||||
|
1. The client calls `GET /api/low-code/entities/{entityName}/download-token` to obtain a single-use download token (valid for 30 seconds). |
||||
|
2. The client calls `GET /api/low-code/entities/{entityName}/export-as-excel` or `GET /api/low-code/entities/{entityName}/export-as-csv` with the token and optional filters. |
||||
|
|
||||
|
### API Endpoints |
||||
|
|
||||
|
| Endpoint | Description | |
||||
|
|----------|-------------| |
||||
|
| `GET /api/low-code/entities/{entityName}/download-token` | Get a single-use download token | |
||||
|
| `GET /api/low-code/entities/{entityName}/export-as-excel` | Export as Excel (.xlsx) | |
||||
|
| `GET /api/low-code/entities/{entityName}/export-as-csv` | Export as CSV (.csv) | |
||||
|
|
||||
|
Export requests accept the same filtering, sorting, and search parameters as the list endpoint. Server-only properties are automatically excluded, and foreign key columns display the referenced entity's display value instead of the raw ID. |
||||
|
|
||||
|
## Custom Commands and Queries |
||||
|
|
||||
|
The Low-Code System allows you to replace or extend the default CRUD operations by implementing custom command and query handlers in C#. |
||||
|
|
||||
|
### Custom Commands |
||||
|
|
||||
|
Create a class that implements `ILcCommand<TResult>` and decorate it with `[CustomCommand]`: |
||||
|
|
||||
|
````csharp |
||||
|
[CustomCommand("Create", "MyApp.Products.Product")] |
||||
|
public class CustomProductCreateCommand : CreateCommand<Product> |
||||
|
{ |
||||
|
public override async Task<Guid> ExecuteWithResultAsync(DynamicCommandArgs commandArgs) |
||||
|
{ |
||||
|
// Your custom create logic here |
||||
|
// ... |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
| Parameter | Description | |
||||
|
|-----------|-------------| |
||||
|
| `commandName` | The command to replace: `"Create"`, `"Update"`, or `"Delete"` | |
||||
|
| `entityName` | Full entity name (e.g., `"MyApp.Products.Product"`) | |
||||
|
|
||||
|
### Custom Queries |
||||
|
|
||||
|
Create a class that implements `ILcQuery<TResult>` and decorate it with `[CustomQuery]`: |
||||
|
|
||||
|
````csharp |
||||
|
[CustomQuery("List", "MyApp.Products.Product")] |
||||
|
public class CustomProductListQuery : ILcQuery<DynamicQueryResult> |
||||
|
{ |
||||
|
public async Task<DynamicQueryResult> ExecuteAsync(DynamicQueryArgs queryArgs) |
||||
|
{ |
||||
|
// Your custom list query logic here |
||||
|
// ... |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
````csharp |
||||
|
[CustomQuery("Single", "MyApp.Products.Product")] |
||||
|
public class CustomProductListQuery : ILcQuery<DynamicEntityDto> |
||||
|
{ |
||||
|
public async Task<DynamicEntityDto> ExecuteAsync(DynamicQueryArgs queryArgs) |
||||
|
{ |
||||
|
// Your custom single query logic here |
||||
|
// ... |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
| Parameter | Description | |
||||
|
|-----------|-------------| |
||||
|
| `queryName` | The query to replace: `"List"` or `"Single"` | |
||||
|
| `entityName` | Full entity name (e.g., `"MyApp.Products.Product"`) | |
||||
|
|
||||
|
Custom commands and queries are automatically discovered and registered at startup. They completely replace the default handler for the specified entity and operation. |
||||
|
|
||||
|
## Internals |
||||
|
|
||||
|
### Domain Layer |
||||
|
|
||||
|
* `DynamicModelManager`: Singleton managing all entity metadata with a layered configuration architecture (Code > JSON > Fluent > Defaults). |
||||
|
* `EntityDescriptor`: Entity definition with properties, foreign keys, interceptors, and UI configuration. |
||||
|
* `EntityPropertyDescriptor`: Property definition with type, validation, UI settings, and foreign key info. |
||||
|
* `IDynamicEntityRepository`: Repository for dynamic entity CRUD operations. |
||||
|
|
||||
|
### Application Layer |
||||
|
|
||||
|
* `DynamicEntityAppService`: CRUD operations for all dynamic entities (Get, GetList, Create, Update, Delete, Export). |
||||
|
* `DynamicEntityUIAppService`: UI definitions, menu items, and page configurations. Provides: |
||||
|
* `GetUiDefinitionAsync(entityName)` — Full UI definition (filters, columns, forms, children, foreign access actions, permissions) |
||||
|
* `GetUiCreationFormDefinitionAsync(entityName)` — Creation form fields with validation rules |
||||
|
* `GetUiEditFormDefinitionAsync(entityName)` — Edit form fields with validation rules |
||||
|
* `GetMenuItemsAsync()` — Menu items for all entities that have a `pageTitle` configured (filtered by permissions) |
||||
|
* `DynamicPermissionDefinitionProvider`: Auto-generates permissions per entity. |
||||
|
* `CustomEndpointExecutor`: Executes JavaScript-based custom endpoints. |
||||
|
|
||||
|
### Database Providers |
||||
|
|
||||
|
**Entity Framework Core**: Dynamic entities are configured as EF Core [shared-type entities](https://learn.microsoft.com/en-us/ef/core/modeling/entity-types?tabs=fluent-api#shared-type-entity-types) via the `ConfigureDynamicEntities()` extension method. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [Attributes & Fluent API](fluent-api.md) |
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Scripting API](scripting-api.md) |
||||
@ -0,0 +1,249 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Add custom business logic to dynamic entity CRUD operations using Interceptors in the ABP Low-Code System. Validate, transform, and react to data changes with JavaScript." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Interceptors |
||||
|
|
||||
|
Interceptors allow you to run custom JavaScript code before, after, or instead of Create, Update, and Delete operations on dynamic entities. |
||||
|
|
||||
|
## Interceptor Types |
||||
|
|
||||
|
| Command | Type | When Executed | |
||||
|
|---------|------|---------------| |
||||
|
| `Create` | `Pre` | Before entity creation — validation, default values | |
||||
|
| `Create` | `Post` | After entity creation — notifications, related data | |
||||
|
| `Create` | `Replace` | Instead of entity creation — **must return the new entity's Id** (see below) | |
||||
|
| `Update` | `Pre` | Before entity update — validation, authorization | |
||||
|
| `Update` | `Post` | After entity update — sync, notifications | |
||||
|
| `Update` | `Replace` | Instead of entity update — no return value needed | |
||||
|
| `Delete` | `Pre` | Before entity deletion — dependency checks | |
||||
|
| `Delete` | `Post` | After entity deletion — cleanup | |
||||
|
| `Delete` | `Replace` | Instead of entity deletion — no return value needed | |
||||
|
|
||||
|
## Defining Interceptors with Attributes |
||||
|
|
||||
|
Use the `[DynamicEntityCommandInterceptor]` attribute on a C# class: |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityCommandInterceptor( |
||||
|
"Create", |
||||
|
InterceptorType.Pre, |
||||
|
"if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
||||
|
)] |
||||
|
[DynamicEntityCommandInterceptor( |
||||
|
"Create", |
||||
|
InterceptorType.Post, |
||||
|
"context.log('Entity created: ' + context.commandArgs.entityId);" |
||||
|
)] |
||||
|
public class Organization |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
The `Name` parameter must be one of: `"Create"`, `"Update"`, or `"Delete"`. The `InterceptorType` can be `Pre`, `Post`, or `Replace`. When `Replace` is used, the default database operation is completely skipped and only your JavaScript handler executes. Multiple interceptors can be added to the same class (`AllowMultiple = true`). |
||||
|
|
||||
|
## Defining Interceptors with Fluent API |
||||
|
|
||||
|
Use the `Interceptors` list on an `EntityDescriptor` to add interceptors programmatically in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer): |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
||||
|
"MyApp.Organizations.Organization", |
||||
|
entity => |
||||
|
{ |
||||
|
entity.Interceptors.Add(new CommandInterceptorDescriptor("Create") |
||||
|
{ |
||||
|
Type = InterceptorType.Pre, |
||||
|
Javascript = "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
||||
|
}); |
||||
|
|
||||
|
entity.Interceptors.Add(new CommandInterceptorDescriptor("Delete") |
||||
|
{ |
||||
|
Type = InterceptorType.Post, |
||||
|
Javascript = "context.log('Deleted: ' + context.commandArgs.entityId);" |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
```` |
||||
|
|
||||
|
See [Attributes & Fluent API](fluent-api.md#adding-interceptors) for more details on Fluent API configuration. |
||||
|
|
||||
|
## Defining Interceptors in model.json |
||||
|
|
||||
|
Add interceptors to the `interceptors` array of an entity: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "LowCodeDemo.Customers.Customer", |
||||
|
"interceptors": [ |
||||
|
{ |
||||
|
"commandName": "Create", |
||||
|
"type": "Pre", |
||||
|
"javascript": "if(context.commandArgs.data['Name'] == 'Invalid') {\n globalError = 'Invalid Customer Name!';\n}" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Interceptor Descriptor |
||||
|
|
||||
|
| Field | Type | Description | |
||||
|
|-------|------|-------------| |
||||
|
| `commandName` | string | `"Create"`, `"Update"`, or `"Delete"` | |
||||
|
| `type` | string | `"Pre"`, `"Post"`, or `"Replace"` | |
||||
|
| `javascript` | string | JavaScript code to execute | |
||||
|
|
||||
|
## JavaScript Context |
||||
|
|
||||
|
Inside interceptor scripts, you have access to: |
||||
|
|
||||
|
### `context.commandArgs` |
||||
|
|
||||
|
| Property / Method | Type | Description | |
||||
|
|----------|------|-------------| |
||||
|
| `data` | object | Entity data dictionary (for Create/Update) | |
||||
|
| `entityId` | string | Entity ID (for Update/Delete) | |
||||
|
| `commandName` | string | Command name (`"Create"`, `"Update"`, or `"Delete"`) | |
||||
|
| `entityName` | string | Full entity name | |
||||
|
| `getValue(name)` | function | Get a property value | |
||||
|
| `setValue(name, value)` | function | Set a property value (Pre-interceptors only) | |
||||
|
| `hasValue(name)` | function | Check if a property exists in the data | |
||||
|
| `removeValue(name)` | function | Remove a property from the data | |
||||
|
|
||||
|
### `context.currentUser` |
||||
|
|
||||
|
| Property / Method | Type | Description | |
||||
|
|----------|------|-------------| |
||||
|
| `isAuthenticated` | bool | Whether user is logged in | |
||||
|
| `id` | string | User ID | |
||||
|
| `userName` | string | Username | |
||||
|
| `email` | string | Email address | |
||||
|
| `name` | string | First name | |
||||
|
| `surName` | string | Last name | |
||||
|
| `phoneNumber` | string | Phone number | |
||||
|
| `phoneNumberVerified` | bool | Whether phone is verified | |
||||
|
| `emailVerified` | bool | Whether email is verified | |
||||
|
| `tenantId` | string | Tenant ID (for multi-tenant apps) | |
||||
|
| `roles` | string[] | User's role names | |
||||
|
| `isInRole(roleName)` | function | Check if user has a specific role | |
||||
|
|
||||
|
### `context.emailSender` |
||||
|
|
||||
|
| Property / Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `isAvailable` | Whether the email sender is configured and available | |
||||
|
| `sendAsync(to, subject, body)` | Send a plain-text email | |
||||
|
| `sendHtmlAsync(to, subject, htmlBody)` | Send an HTML email | |
||||
|
|
||||
|
### Logging |
||||
|
|
||||
|
| Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `context.log(message)` | Log an informational message | |
||||
|
| `context.logWarning(message)` | Log a warning message | |
||||
|
| `context.logError(message)` | Log an error message | |
||||
|
|
||||
|
> Use these methods instead of `console.log` (which is blocked in the sandbox). |
||||
|
|
||||
|
### `db` (Database API) |
||||
|
|
||||
|
Full access to the [Scripting API](scripting-api.md) for querying and mutating data. |
||||
|
|
||||
|
### `globalError` |
||||
|
|
||||
|
Set this variable to a string to **abort** the operation and return an error: |
||||
|
|
||||
|
```javascript |
||||
|
globalError = 'Cannot delete this entity!'; |
||||
|
``` |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## Examples |
||||
|
|
||||
|
### Pre-Create: Validation |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Create", |
||||
|
"type": "Pre", |
||||
|
"javascript": "if(!context.commandArgs.data['Name']) {\n globalError = 'Organization name is required!';\n}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Post-Create: Email Notification |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Create", |
||||
|
"type": "Post", |
||||
|
"javascript": "if(context.currentUser.isAuthenticated && context.emailSender) {\n await context.emailSender.sendAsync(\n context.currentUser.email,\n 'New Order Created',\n 'Order total: $' + context.commandArgs.data['TotalAmount']\n );\n}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Pre-Update: Role-Based Authorization |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Update", |
||||
|
"type": "Pre", |
||||
|
"javascript": "if(context.commandArgs.data['IsDelivered']) {\n if(!context.currentUser.roles.includes('admin')) {\n globalError = 'Only administrators can mark orders as delivered!';\n }\n}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Pre-Delete: Business Rule Check |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Delete", |
||||
|
"type": "Pre", |
||||
|
"javascript": "var project = await db.get('LowCodeDemo.Projects.Project', context.commandArgs.entityId);\nif(project.Budget > 100000) {\n globalError = 'Cannot delete high-budget projects!';\n}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Pre-Update: Negative Value Check |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Update", |
||||
|
"type": "Pre", |
||||
|
"javascript": "if(context.commandArgs.data['Quantity'] < 0) {\n globalError = 'Quantity cannot be negative!';\n}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Replace-Create: Custom Insert Logic |
||||
|
|
||||
|
When you need to completely replace the default create operation with custom logic: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Create", |
||||
|
"type": "Replace", |
||||
|
"javascript": "var data = context.commandArgs.data;\ndata['Code'] = 'PRD-' + Date.now();\nvar result = await db.insert('LowCodeDemo.Products.Product', data);\ncontext.log('Product created with custom code: ' + data['Code']);\nreturn result.Id;" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> **Important:** `Replace-Create` interceptors **must** return the new entity's `Id` (Guid). The system uses this value to fetch and return the created entity. Use `return result.Id;` after `db.insert(...)`. |
||||
|
> |
||||
|
> `Replace-Update` and `Replace-Delete` interceptors do not need to return a value. |
||||
|
|
||||
|
### Pre-Update: Self-Reference Check |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"commandName": "Update", |
||||
|
"type": "Pre", |
||||
|
"javascript": "if(context.commandArgs.data.ParentCategoryId === context.commandArgs.entityId) {\n globalError = 'A category cannot be its own parent!';\n}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [Scripting API](scripting-api.md) |
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Custom Endpoints](custom-endpoints.md) |
||||