|
21 | 21 | import java.util.Map; |
22 | 22 | import java.util.Optional; |
23 | 23 | import java.util.Set; |
| 24 | +import java.util.TreeMap; |
| 25 | +import java.util.stream.Collectors; |
24 | 26 |
|
25 | 27 | /** |
26 | 28 | * Facade service over the Neo4j graph backend. |
@@ -400,7 +402,7 @@ public long countEdges() { |
400 | 402 | public long countDistinctFiles() { |
401 | 403 | try (Transaction tx = graphDb.beginTx()) { |
402 | 404 | var result = tx.execute( |
403 | | - "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL " |
| 405 | + "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL AND n.filePath <> '' " |
404 | 406 | + "RETURN count(DISTINCT n.filePath) AS cnt"); |
405 | 407 | if (result.hasNext()) { |
406 | 408 | return ((Number) result.next().get("cnt")).longValue(); |
@@ -646,6 +648,260 @@ public Map<String, Object> getTopology() { |
646 | 648 | return topology; |
647 | 649 | } |
648 | 650 |
|
| 651 | + // --- Stats aggregation queries (Cypher-only, no node hydration) --- |
| 652 | + |
| 653 | + /** |
| 654 | + * Compute all categorized stats via Cypher aggregation. |
| 655 | + * Never loads full nodes into heap — safe for 100K+ node graphs. |
| 656 | + */ |
| 657 | + public Map<String, Object> computeAggregateStats() { |
| 658 | + Map<String, Object> result = new LinkedHashMap<>(); |
| 659 | + result.put("graph", computeGraphStats()); |
| 660 | + result.put("languages", computeLanguageStats()); |
| 661 | + result.put("frameworks", computeFrameworkStats()); |
| 662 | + result.put("infra", computeInfraStats()); |
| 663 | + result.put("connections", computeConnectionStats()); |
| 664 | + result.put("auth", computeAuthStats()); |
| 665 | + result.put("architecture", computeArchitectureStats()); |
| 666 | + return result; |
| 667 | + } |
| 668 | + |
| 669 | + /** |
| 670 | + * Compute stats for a single category via Cypher aggregation. |
| 671 | + */ |
| 672 | + public Map<String, Object> computeAggregateCategoryStats(String category) { |
| 673 | + return switch (category.toLowerCase()) { |
| 674 | + case "graph" -> computeGraphStats(); |
| 675 | + case "languages" -> computeLanguageStats(); |
| 676 | + case "frameworks" -> computeFrameworkStats(); |
| 677 | + case "infra" -> computeInfraStats(); |
| 678 | + case "connections" -> computeConnectionStats(); |
| 679 | + case "auth" -> computeAuthStats(); |
| 680 | + case "architecture" -> computeArchitectureStats(); |
| 681 | + default -> null; |
| 682 | + }; |
| 683 | + } |
| 684 | + |
| 685 | + private Map<String, Object> computeGraphStats() { |
| 686 | + Map<String, Object> graph = new LinkedHashMap<>(); |
| 687 | + graph.put("nodes", count()); |
| 688 | + graph.put("edges", countEdges()); |
| 689 | + graph.put("files", countDistinctFiles()); |
| 690 | + return graph; |
| 691 | + } |
| 692 | + |
| 693 | + private Map<String, Object> computeLanguageStats() { |
| 694 | + Map<String, Long> langCounts = new TreeMap<>(); |
| 695 | + try (Transaction tx = graphDb.beginTx()) { |
| 696 | + var result = tx.execute( |
| 697 | + "MATCH (n:CodeNode) WHERE n.prop_language IS NOT NULL " |
| 698 | + + "RETURN toLower(n.prop_language) AS lang, count(n) AS cnt"); |
| 699 | + while (result.hasNext()) { |
| 700 | + var row = result.next(); |
| 701 | + String lang = String.valueOf(row.get("lang")).trim(); |
| 702 | + if (!lang.isBlank()) { |
| 703 | + langCounts.merge(lang, ((Number) row.get("cnt")).longValue(), Long::sum); |
| 704 | + } |
| 705 | + } |
| 706 | + } |
| 707 | + if (langCounts.isEmpty()) { |
| 708 | + for (Map<String, Object> row : countByFileExtension()) { |
| 709 | + String ext = String.valueOf(row.get("ext")).trim().toLowerCase(); |
| 710 | + String lang = extensionToLanguage(ext); |
| 711 | + if (lang != null) { |
| 712 | + langCounts.merge(lang, ((Number) row.get("cnt")).longValue(), Long::sum); |
| 713 | + } |
| 714 | + } |
| 715 | + } |
| 716 | + return new LinkedHashMap<>(sortByValueDesc(langCounts)); |
| 717 | + } |
| 718 | + |
| 719 | + private Map<String, Object> computeFrameworkStats() { |
| 720 | + Map<String, Long> fwCounts = new TreeMap<>(); |
| 721 | + try (Transaction tx = graphDb.beginTx()) { |
| 722 | + var result = tx.execute( |
| 723 | + "MATCH (n:CodeNode) WHERE n.prop_framework IS NOT NULL " |
| 724 | + + "RETURN n.prop_framework AS fw, count(n) AS cnt"); |
| 725 | + while (result.hasNext()) { |
| 726 | + var row = result.next(); |
| 727 | + String fw = String.valueOf(row.get("fw")).trim(); |
| 728 | + if (!fw.isBlank()) { |
| 729 | + fwCounts.merge(fw, ((Number) row.get("cnt")).longValue(), Long::sum); |
| 730 | + } |
| 731 | + } |
| 732 | + } |
| 733 | + return new LinkedHashMap<>(sortByValueDesc(fwCounts)); |
| 734 | + } |
| 735 | + |
| 736 | + private Map<String, Object> computeInfraStats() { |
| 737 | + Map<String, Object> infra = new LinkedHashMap<>(); |
| 738 | + |
| 739 | + Map<String, Long> databases = new TreeMap<>(); |
| 740 | + try (Transaction tx = graphDb.beginTx()) { |
| 741 | + var result = tx.execute( |
| 742 | + "MATCH (n:CodeNode) WHERE n.kind = 'database_connection' AND n.prop_db_type IS NOT NULL " |
| 743 | + + "RETURN n.prop_db_type AS dbType, count(n) AS cnt"); |
| 744 | + while (result.hasNext()) { |
| 745 | + var row = result.next(); |
| 746 | + String dbType = normalizeDbType(String.valueOf(row.get("dbType"))); |
| 747 | + if (dbType != null) { |
| 748 | + databases.merge(dbType, ((Number) row.get("cnt")).longValue(), Long::sum); |
| 749 | + } |
| 750 | + } |
| 751 | + } |
| 752 | + infra.put("databases", sortByValueDesc(databases)); |
| 753 | + |
| 754 | + Map<String, Long> messaging = new TreeMap<>(); |
| 755 | + try (Transaction tx = graphDb.beginTx()) { |
| 756 | + var result = tx.execute( |
| 757 | + "MATCH (n:CodeNode) WHERE n.kind IN ['topic', 'queue', 'message_queue'] " |
| 758 | + + "RETURN coalesce(n.prop_protocol, n.label, 'unknown') AS protocol, count(n) AS cnt"); |
| 759 | + while (result.hasNext()) { |
| 760 | + var row = result.next(); |
| 761 | + messaging.merge(String.valueOf(row.get("protocol")), ((Number) row.get("cnt")).longValue(), Long::sum); |
| 762 | + } |
| 763 | + } |
| 764 | + infra.put("messaging", sortByValueDesc(messaging)); |
| 765 | + |
| 766 | + Map<String, Long> cloud = new TreeMap<>(); |
| 767 | + try (Transaction tx = graphDb.beginTx()) { |
| 768 | + var result = tx.execute( |
| 769 | + "MATCH (n:CodeNode) WHERE n.kind IN ['azure_resource', 'infra_resource'] " |
| 770 | + + "RETURN coalesce(n.prop_resource_type, n.label, 'unknown') AS resType, count(n) AS cnt"); |
| 771 | + while (result.hasNext()) { |
| 772 | + var row = result.next(); |
| 773 | + cloud.merge(String.valueOf(row.get("resType")), ((Number) row.get("cnt")).longValue(), Long::sum); |
| 774 | + } |
| 775 | + } |
| 776 | + infra.put("cloud", sortByValueDesc(cloud)); |
| 777 | + |
| 778 | + return infra; |
| 779 | + } |
| 780 | + |
| 781 | + private Map<String, Object> computeConnectionStats() { |
| 782 | + Map<String, Object> connections = new LinkedHashMap<>(); |
| 783 | + try (Transaction tx = graphDb.beginTx()) { |
| 784 | + var restResult = tx.execute( |
| 785 | + "MATCH (n:CodeNode) WHERE n.kind = 'endpoint' " |
| 786 | + + "AND (n.prop_protocol IS NULL OR n.prop_protocol <> 'grpc') " |
| 787 | + + "RETURN coalesce(toUpper(n.prop_http_method), 'UNKNOWN') AS method, count(n) AS cnt"); |
| 788 | + Map<String, Long> restByMethod = new TreeMap<>(); |
| 789 | + while (restResult.hasNext()) { |
| 790 | + var row = restResult.next(); |
| 791 | + restByMethod.put(String.valueOf(row.get("method")), ((Number) row.get("cnt")).longValue()); |
| 792 | + } |
| 793 | + long restTotal = restByMethod.values().stream().mapToLong(Long::longValue).sum(); |
| 794 | + Map<String, Object> rest = new LinkedHashMap<>(); |
| 795 | + rest.put("total", restTotal); |
| 796 | + rest.put("by_method", sortByValueDesc(restByMethod)); |
| 797 | + connections.put("rest", rest); |
| 798 | + |
| 799 | + var grpcResult = tx.execute( |
| 800 | + "MATCH (n:CodeNode) WHERE n.kind = 'endpoint' AND n.prop_protocol = 'grpc' RETURN count(n) AS cnt"); |
| 801 | + connections.put("grpc", grpcResult.hasNext() ? ((Number) grpcResult.next().get("cnt")).longValue() : 0L); |
| 802 | + |
| 803 | + var wsResult = tx.execute( |
| 804 | + "MATCH (n:CodeNode) WHERE n.kind = 'websocket_endpoint' RETURN count(n) AS cnt"); |
| 805 | + connections.put("websocket", wsResult.hasNext() ? ((Number) wsResult.next().get("cnt")).longValue() : 0L); |
| 806 | + |
| 807 | + var prodResult = tx.execute( |
| 808 | + "MATCH ()-[r:RELATES_TO]->() WHERE r.kind IN ['produces', 'publishes'] RETURN count(r) AS cnt"); |
| 809 | + connections.put("producers", prodResult.hasNext() ? ((Number) prodResult.next().get("cnt")).longValue() : 0L); |
| 810 | + |
| 811 | + var consResult = tx.execute( |
| 812 | + "MATCH ()-[r:RELATES_TO]->() WHERE r.kind IN ['consumes', 'listens'] RETURN count(r) AS cnt"); |
| 813 | + connections.put("consumers", consResult.hasNext() ? ((Number) consResult.next().get("cnt")).longValue() : 0L); |
| 814 | + } |
| 815 | + return connections; |
| 816 | + } |
| 817 | + |
| 818 | + private Map<String, Object> computeAuthStats() { |
| 819 | + Map<String, Long> authCounts = new TreeMap<>(); |
| 820 | + try (Transaction tx = graphDb.beginTx()) { |
| 821 | + var guardResult = tx.execute( |
| 822 | + "MATCH (n:CodeNode) WHERE n.kind = 'guard' AND n.prop_auth_type IS NOT NULL " |
| 823 | + + "RETURN n.prop_auth_type AS authType, count(n) AS cnt"); |
| 824 | + while (guardResult.hasNext()) { |
| 825 | + var row = guardResult.next(); |
| 826 | + String authType = String.valueOf(row.get("authType")).trim(); |
| 827 | + if (!authType.isBlank()) { |
| 828 | + authCounts.merge(authType, ((Number) row.get("cnt")).longValue(), Long::sum); |
| 829 | + } |
| 830 | + } |
| 831 | + var fwResult = tx.execute( |
| 832 | + "MATCH (n:CodeNode) WHERE n.prop_framework STARTS WITH 'auth:' " |
| 833 | + + "RETURN n.prop_framework AS fw, count(n) AS cnt"); |
| 834 | + while (fwResult.hasNext()) { |
| 835 | + var row = fwResult.next(); |
| 836 | + String fw = String.valueOf(row.get("fw")).trim(); |
| 837 | + String authType = fw.substring("auth:".length()).trim(); |
| 838 | + if (!authType.isEmpty()) { |
| 839 | + authCounts.merge(authType, ((Number) row.get("cnt")).longValue(), Long::sum); |
| 840 | + } |
| 841 | + } |
| 842 | + } |
| 843 | + return new LinkedHashMap<>(sortByValueDesc(authCounts)); |
| 844 | + } |
| 845 | + |
| 846 | + private Map<String, Object> computeArchitectureStats() { |
| 847 | + Map<String, Object> arch = new LinkedHashMap<>(); |
| 848 | + Map<String, String> kindToLabel = Map.of( |
| 849 | + "class", "classes", "interface", "interfaces", |
| 850 | + "abstract_class", "abstract_classes", "enum", "enums", |
| 851 | + "annotation_type", "annotation_types", "module", "modules", |
| 852 | + "method", "methods"); |
| 853 | + List<String> archKinds = List.of("class", "interface", "abstract_class", "enum", |
| 854 | + "annotation_type", "module", "method"); |
| 855 | + try (Transaction tx = graphDb.beginTx()) { |
| 856 | + var result = tx.execute( |
| 857 | + "MATCH (n:CodeNode) WHERE n.kind IN $kinds RETURN n.kind AS kind, count(n) AS cnt", |
| 858 | + Map.of("kinds", archKinds)); |
| 859 | + while (result.hasNext()) { |
| 860 | + var row = result.next(); |
| 861 | + String kind = (String) row.get("kind"); |
| 862 | + long cnt = ((Number) row.get("cnt")).longValue(); |
| 863 | + if (cnt > 0) { |
| 864 | + arch.put(kindToLabel.getOrDefault(kind, kind), cnt); |
| 865 | + } |
| 866 | + } |
| 867 | + } |
| 868 | + return arch; |
| 869 | + } |
| 870 | + |
| 871 | + private static final Map<String, String> STATS_DB_TYPE_NORMALIZE = Map.ofEntries( |
| 872 | + Map.entry("mysql", "MySQL"), Map.entry("postgresql", "PostgreSQL"), |
| 873 | + Map.entry("postgres", "PostgreSQL"), Map.entry("sqlserver", "SQL Server"), |
| 874 | + Map.entry("mssql", "SQL Server"), Map.entry("oracle", "Oracle"), |
| 875 | + Map.entry("h2", "H2"), Map.entry("sqlite", "SQLite"), |
| 876 | + Map.entry("mariadb", "MariaDB"), Map.entry("mongo", "MongoDB"), |
| 877 | + Map.entry("mongodb", "MongoDB"), Map.entry("redis", "Redis"), |
| 878 | + Map.entry("neo4j", "Neo4j")); |
| 879 | + |
| 880 | + private static String normalizeDbType(String raw) { |
| 881 | + String lower = raw.trim().toLowerCase(); |
| 882 | + if (lower.contains("@")) lower = lower.substring(0, lower.indexOf('@')); |
| 883 | + return STATS_DB_TYPE_NORMALIZE.getOrDefault(lower, raw.trim()); |
| 884 | + } |
| 885 | + |
| 886 | + private static String extensionToLanguage(String ext) { |
| 887 | + return switch (ext) { |
| 888 | + case "java" -> "java"; case "kt", "kts" -> "kotlin"; |
| 889 | + case "py" -> "python"; case "js", "mjs", "cjs" -> "javascript"; |
| 890 | + case "ts", "tsx" -> "typescript"; case "go" -> "go"; |
| 891 | + case "rs" -> "rust"; case "cs" -> "csharp"; |
| 892 | + case "scala" -> "scala"; case "cpp", "cc", "cxx" -> "cpp"; |
| 893 | + case "proto" -> "protobuf"; default -> null; |
| 894 | + }; |
| 895 | + } |
| 896 | + |
| 897 | + private static <K> Map<K, Long> sortByValueDesc(Map<K, Long> map) { |
| 898 | + return map.entrySet().stream() |
| 899 | + .sorted(Map.Entry.<K, Long>comparingByValue().reversed()) |
| 900 | + .collect(Collectors.toMap( |
| 901 | + Map.Entry::getKey, Map.Entry::getValue, |
| 902 | + (a, b) -> a, LinkedHashMap::new)); |
| 903 | + } |
| 904 | + |
649 | 905 | // --- Internal helpers --- |
650 | 906 |
|
651 | 907 | /** |
|
0 commit comments