001package gudusoft.gsqlparser.lineage2.cli; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.ir.semantic.catalog.Catalog; 005import gudusoft.gsqlparser.ir.semantic.catalog.CatalogTable; 006import gudusoft.gsqlparser.lineage2.api.LineageAnalyzer; 007import gudusoft.gsqlparser.lineage2.api.LineageConfig; 008import gudusoft.gsqlparser.util.json.JSON; 009 010import java.io.File; 011import java.io.IOException; 012import java.nio.charset.StandardCharsets; 013import java.nio.file.Files; 014import java.util.List; 015import java.util.Map; 016 017/** 018 * Command-line entry point for the lineage2 engine — the invocation 019 * contract pinned by the verification harness 020 * ({@code scripts/lineage2/gold/check_gold.py} and {@code compare.sh}): 021 * 022 * <pre> 023 * java -cp gsqlparser.jar gudusoft.gsqlparser.lineage2.cli.Lineage2Cli \ 024 * --sql <file> [--vendor mssql] [--artifact-locator <name>] \ 025 * [--workspace <ws>] [--default-database <db>] \ 026 * [--default-schema <schema>] [--catalog <catalog.json>] 027 * </pre> 028 * 029 * <p>Prints the contract JSON document on stdout (one trailing newline) 030 * and exits 0. SQL-level failures degrade into document diagnostics per 031 * contract §2 and still exit 0; usage and I/O errors print to stderr and 032 * exit 2. 033 * 034 * <p>The {@code --catalog} sidecar uses the gold-fixture format: 035 * {@code {"defaultDatabase", "defaultSchema", "objects": [{"name", "kind", 036 * "columns": [...]}], "primaryKeys"?, "synonyms"?}}. Object names and 037 * columns are loaded into the Semantic IR {@link Catalog} DTO; 038 * {@code kind}/{@code primaryKeys}/{@code synonyms} are parsed but not yet 039 * consumed (they feed the BindingGate and LOOKUP stories, US-025+). 040 * 041 * <p>Internal API: not part of the public gsqlparser surface. 042 */ 043public final class Lineage2Cli { 044 045 private Lineage2Cli() { 046 // entry point — no instances 047 } 048 049 public static void main(String[] args) { 050 try { 051 System.out.println(run(args)); 052 } catch (UsageException | IOException e) { 053 System.err.println("lineage2: " + e.getMessage()); 054 System.exit(2); 055 } 056 } 057 058 /** 059 * Thrown on bad command-line arguments (exit code 2). 060 * 061 * <p>Public alongside {@link #run(String[])}: it is part of that method's 062 * behaviour contract, so a caller catching it needs the type to keep its 063 * name in the obfuscated jar.</p> 064 * 065 * @since 4.2.6 066 */ 067 public static final class UsageException extends RuntimeException { 068 UsageException(String message) { 069 super(message); 070 } 071 } 072 073 /** 074 * Embeddable core of {@link #main}: parses arguments, runs the analyzer, 075 * and RETURNS the contract JSON (without trailing newline) instead of 076 * printing it and terminating the JVM. 077 * 078 * <p>Public because this is the form a caller actually wants when driving 079 * the tool in-process: {@link #main} writes to stdout and calls 080 * {@link System#exit}, which would take the host application down with it. 081 * It is also what makes the CLI testable against the shipped 082 * (ProGuard-obfuscated) jar - a package-private method is renamed there and 083 * cannot be reached, by a test or by anyone else.</p> 084 * 085 * @param args the same arguments {@link #main} accepts 086 * @return the contract JSON produced for those arguments 087 * @throws IOException if the SQL or artifact input cannot be read 088 * @since 4.2.6 089 */ 090 public static String run(String[] args) throws IOException { 091 String sqlPath = null; 092 String vendorName = "mssql"; 093 String artifactLocator = null; 094 String workspace = ""; 095 String defaultDatabase = ""; 096 String defaultSchema = ""; 097 String catalogPath = null; 098 for (int i = 0; i < args.length; i++) { 099 String arg = args[i]; 100 if (!arg.startsWith("--")) { 101 throw new UsageException("unexpected argument: " + arg); 102 } 103 if (i + 1 >= args.length) { 104 throw new UsageException(arg + " needs a value"); 105 } 106 String value = args[++i]; 107 switch (arg) { 108 case "--sql": 109 sqlPath = value; 110 break; 111 case "--vendor": 112 vendorName = value; 113 break; 114 case "--artifact-locator": 115 artifactLocator = value; 116 break; 117 case "--workspace": 118 workspace = value; 119 break; 120 case "--default-database": 121 defaultDatabase = value; 122 break; 123 case "--default-schema": 124 defaultSchema = value; 125 break; 126 case "--catalog": 127 catalogPath = value; 128 break; 129 default: 130 throw new UsageException("unknown option: " + arg); 131 } 132 } 133 if (sqlPath == null) { 134 throw new UsageException("--sql <file> is required"); 135 } 136 File sqlFile = new File(sqlPath); 137 if (!sqlFile.isFile()) { 138 throw new UsageException("not a readable file: " + sqlPath); 139 } 140 141 EDbVendor vendor = resolveVendor(vendorName); 142 Catalog catalog = (catalogPath == null) ? null : loadCatalog(catalogPath); 143 LineageConfig config = LineageConfig.builder() 144 .workspaceId(workspace) 145 .defaultDatabase(defaultDatabase) 146 .defaultSchema(defaultSchema) 147 .artifactLocator(artifactLocator == null ? "" : artifactLocator) 148 .build(); 149 return LineageAnalyzer.analyze(sqlFile, vendor, catalog, config).toJson(); 150 } 151 152 /** Contract vendor string → {@link EDbVendor} (e.g. "mssql" → dbvmssql). */ 153 static EDbVendor resolveVendor(String name) { 154 for (EDbVendor v : EDbVendor.values()) { 155 String enumName = v.name(); 156 String bare = enumName.startsWith("dbv") 157 ? enumName.substring(3) : enumName; 158 if (bare.equalsIgnoreCase(name)) { 159 return v; 160 } 161 } 162 throw new UsageException("unknown vendor: " + name); 163 } 164 165 /** Load a gold-fixture-format catalog sidecar into the Catalog DTO. */ 166 /** 167 * Reads a catalog sidecar JSON file into a {@link Catalog}. 168 * 169 * <p>Public for the same reason as {@link #run(String[])}: it is a useful 170 * entry point for a caller assembling the same inputs the CLI takes, and a 171 * package-private method is renamed in the shipped obfuscated jar.</p> 172 * 173 * @param path path to the catalog sidecar JSON 174 * @return the parsed catalog 175 * @throws IOException if the file cannot be read or parsed 176 * @since 4.2.6 177 */ 178 public static Catalog loadCatalog(String path) throws IOException { 179 String text = new String( 180 Files.readAllBytes(new File(path).toPath()), 181 StandardCharsets.UTF_8); 182 Object parsed = JSON.parseObject(text); 183 if (!(parsed instanceof Map)) { 184 throw new UsageException("catalog file is not a JSON object: " + path); 185 } 186 Map<?, ?> root = (Map<?, ?>) parsed; 187 Object objects = root.get("objects"); 188 Catalog.Builder builder = Catalog.builder(); 189 if (objects instanceof List) { 190 for (Object entry : (List<?>) objects) { 191 if (!(entry instanceof Map)) { 192 throw new UsageException( 193 "catalog objects[] entry is not an object: " + path); 194 } 195 Map<?, ?> object = (Map<?, ?>) entry; 196 Object name = object.get("name"); 197 if (!(name instanceof String)) { 198 throw new UsageException( 199 "catalog object without a name: " + path); 200 } 201 CatalogTable.Builder table = 202 CatalogTable.builder((String) name); 203 Object columns = object.get("columns"); 204 if (columns instanceof List) { 205 for (Object column : (List<?>) columns) { 206 if (column instanceof String) { 207 table.addColumn((String) column); 208 } 209 } 210 } 211 builder.addTable(table.build()); 212 } 213 } 214 return builder.build(); 215 } 216}