001package gudusoft.gsqlparser.lineage.v2.producer; 002 003import java.util.ArrayList; 004import java.util.Collections; 005import java.util.List; 006 007import gudusoft.gsqlparser.dlineage.DataFlowAnalyzer; 008import gudusoft.gsqlparser.dlineage.dataflow.metadata.MetadataReader; 009import gudusoft.gsqlparser.dlineage.dataflow.model.ModelBindingManager; 010import gudusoft.gsqlparser.dlineage.dataflow.model.Option; 011import gudusoft.gsqlparser.dlineage.dataflow.model.SqlInfo; 012import gudusoft.gsqlparser.dlineage.dataflow.model.SqllrObservations; 013 014/** 015 * Produces a SQL Lineage Representation (SQL-LR) L1 document from SQL inputs with GSP's lineage engine 016 * (plan {@code docs/designs/data-lineage/06-sqllr-m2-producer-plan-zh.md}). 017 * 018 * <p>The producer runs its own analysis with fixed options ({@link ProducerOptions}) and an observation recorder, 019 * builds an ExplainGraph from what the analysis observed, folds it with the shared fold kernel of 020 * {@code lineage.v2.fold}, and writes the Facts, Occurrences and writes of the L1 core. It never changes what a 021 * legacy {@code DataFlowAnalyzer} run of the caller outputs.</p> 022 * 023 * <p>The analysis, its projection ({@code lineage.v2.shape}) and {@code dispose()} run on the calling thread; the 024 * document is built from the projection after {@code dispose()}, and could be built on any thread (plan 09, slice 025 * 1b). The result holds plain values.</p> 026 * 027 * @since 4.2.17 028 */ 029public final class SqllrProducer { 030 031 private SqllrProducer() { 032 } 033 034 /** 035 * Analyses the inputs together (DDL and views in one input are visible to the others, in input order) and 036 * returns the document. 037 * 038 * @throws SqllrProducerException when the inputs or the configuration are refused 039 */ 040 public static ProducerResult produce(List<SqllrInput> inputs, ProducerConfig config) { 041 return produce(inputs, config, null); 042 } 043 044 /** Tests only: {@code prepare} sees the recorder before the analysis runs (failure injection). */ 045 static ProducerResult produce(List<SqllrInput> inputs, ProducerConfig config, 046 java.util.function.Consumer<SqllrObservations> prepare) { 047 return produce(inputs, config, prepare, null); 048 } 049 050 /** 051 * What the last analysis of a production saw (plan 09, Section 5.4), filled by {@link #produce(List, 052 * ProducerConfig, java.util.function.Consumer, Capture)} for the correctness runner and the coverage probe. It 053 * holds plain values only, so it stays usable after {@code dispose()} and on another thread. 054 */ 055 static final class Capture { 056 private final List<String> inputTexts = new ArrayList<String>(); 057 private final List<String> analysedTexts = new ArrayList<String>(); 058 private final List<int[]> blanked = new ArrayList<int[]>(); 059 private int analyses; 060 private String legacyXml; 061 private String legacyJson; 062 private final java.util.Map<Long, Long> relationOwners = new java.util.TreeMap<Long, Long>(); 063 private final java.util.Map<Long, String> unitTexts = new java.util.LinkedHashMap<Long, String>(); 064 private ProducerResult result; 065 private gudusoft.gsqlparser.dlineage.dataflow.model.SqllrSnapshot snapshot; 066 private String snapshotFailure; 067 private final java.util.Map<Long, String> ledgerRelations = new java.util.TreeMap<Long, String>(); 068 private final java.util.Map<Long, String> ledgerElementEvents = new java.util.TreeMap<Long, String>(); 069 private final java.util.Map<Long, String> ledgerReferenceEvents = new java.util.TreeMap<Long, String>(); 070 private final java.util.Map<Long, Long> ledgerRelationStatements = new java.util.TreeMap<Long, Long>(); 071 private final java.util.Map<Long, Long> relationPublishers = new java.util.TreeMap<Long, Long>(); 072 private final java.util.Set<Long> ledgerGraphed = new java.util.TreeSet<Long>(); 073 private gudusoft.gsqlparser.lineage.v2.shape.Shape shape; 074 /** Tests only: run on the analysis thread before dispose, with the live objects still at hand. */ 075 java.util.function.Consumer<gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder> shapeCheck; 076 /** Tests only: build the document on a thread of its own (it reads the shape, never the analysis). */ 077 boolean buildOnAnotherThread; 078 079 /** The shape the document was built from (plan 09, slice 1b). */ 080 gudusoft.gsqlparser.lineage.v2.shape.Shape getShape() { 081 return shape; 082 } 083 private final java.util.Map<Long, List<Long>> ledgerEventEdges = new java.util.TreeMap<Long, List<Long>>(); 084 private final java.util.Map<Long, List<Long>> ledgerEventDiagnostics = 085 new java.util.TreeMap<Long, List<Long>>(); 086 private final java.util.Map<Long, List<Long>> ledgerEventNodes = new java.util.TreeMap<Long, List<Long>>(); 087 088 /** Shadow ledger: event sequence -> nodes it stands for without an edge (a merged hop's field). */ 089 java.util.Map<Long, List<Long>> getLedgerEventNodes() { 090 return Collections.unmodifiableMap(ledgerEventNodes); 091 } 092 093 /** Shadow ledger: event sequence -> refs of the micro-edges published for it. */ 094 java.util.Map<Long, List<Long>> getLedgerEventEdges() { 095 return Collections.unmodifiableMap(ledgerEventEdges); 096 } 097 098 /** Shadow ledger: event sequence -> diagnostics of the UNKNOWN_INPUTs published for it. */ 099 java.util.Map<Long, List<Long>> getLedgerEventDiagnostics() { 100 return Collections.unmodifiableMap(ledgerEventDiagnostics); 101 } 102 103 /** Shadow ledger: the refs of the statements a statement graph was built for. */ 104 java.util.Set<Long> getLedgerGraphed() { 105 return Collections.unmodifiableSet(ledgerGraphed); 106 } 107 108 /** The value snapshot of the last analysis (plan 09, Section 3.1), or null when it could not be built. */ 109 gudusoft.gsqlparser.dlineage.dataflow.model.SqllrSnapshot getSnapshot() { 110 return snapshot; 111 } 112 113 String getSnapshotFailure() { 114 return snapshotFailure; 115 } 116 117 /** Shadow ledger (plan 09, slice 1a): relationship model id -> how the claiming statement graph handled it. */ 118 java.util.Map<Long, String> getLedgerRelations() { 119 return Collections.unmodifiableMap(ledgerRelations); 120 } 121 122 /** Shadow ledger: relationship model id -> the statement whose graph claimed it. */ 123 java.util.Map<Long, Long> getLedgerRelationStatements() { 124 return Collections.unmodifiableMap(ledgerRelationStatements); 125 } 126 127 /** Shadow ledger: element event sequence -> outcome. */ 128 java.util.Map<Long, String> getLedgerElementEvents() { 129 return Collections.unmodifiableMap(ledgerElementEvents); 130 } 131 132 /** Shadow ledger: reference event sequence -> outcome. */ 133 java.util.Map<Long, String> getLedgerReferenceEvents() { 134 return Collections.unmodifiableMap(ledgerReferenceEvents); 135 } 136 137 /** 138 * Shadow ledger: frozen relationship model id -> the ref of the published statement its target event maps to 139 * (the event {@code StatementGraph.valueEdges} reads, through {@code DocumentBuilder.publisher}), or -1 when no 140 * published statement encloses it; absent when the relationship has no recorded target. 141 */ 142 java.util.Map<Long, Long> getRelationPublishers() { 143 return Collections.unmodifiableMap(relationPublishers); 144 } 145 146 /** Source unit ref -> the decoded text its positions are offsets into (inputs and derived units). */ 147 java.util.Map<Long, String> getUnitTexts() { 148 return Collections.unmodifiableMap(unitTexts); 149 } 150 151 /** 152 * Legacy relationship id -> the ref of the published statement that owns its target (the statement the 153 * analysis recorded for the relationship, or the nearest published one enclosing it). A relationship with no 154 * recorded target or no published owner is absent. This is the recorded ownership the coverage census uses 155 * (plan 08, Section 14), never a position guess. 156 */ 157 java.util.Map<Long, Long> getRelationOwners() { 158 return Collections.unmodifiableMap(relationOwners); 159 } 160 161 /** The texts of the inputs, in input order. */ 162 List<String> getInputTexts() { 163 return Collections.unmodifiableList(inputTexts); 164 } 165 166 /** The texts of the last analysis: the input texts with every statement that did not parse blanked. */ 167 List<String> getAnalysedTexts() { 168 return Collections.unmodifiableList(analysedTexts); 169 } 170 171 /** {unit index, UTF-16 start, UTF-16 end} of each blanked statement, in the order they were found. */ 172 List<int[]> getBlanked() { 173 return Collections.unmodifiableList(blanked); 174 } 175 176 int getAnalyses() { 177 return analyses; 178 } 179 180 /** The legacy XML of the last analysis, serialized on the analysis thread before {@code dispose()}. */ 181 String getLegacyXml() { 182 return legacyXml; 183 } 184 185 /** The legacy JSON model of the last analysis, serialized like {@link #getLegacyXml()}. */ 186 String getLegacyJson() { 187 return legacyJson; 188 } 189 190 ProducerResult getResult() { 191 return result; 192 } 193 } 194 195 /** {@code capture}, when given, is filled from the analysis that published the document. */ 196 static ProducerResult produce(List<SqllrInput> inputs, ProducerConfig config, 197 java.util.function.Consumer<SqllrObservations> prepare, Capture capture) { 198 if (inputs == null || inputs.isEmpty()) { 199 throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "no input"); 200 } 201 if (config == null) { 202 throw new IllegalArgumentException("no config"); 203 } 204 Option option = ProducerOptions.option(config); 205 String[] texts = new String[inputs.size()]; 206 for (int i = 0; i < texts.length; i++) { 207 String text = inputs.get(i).getText(); 208 if (MetadataReader.isMetadata(text) || MetadataReader.isGrabit(text) || MetadataReader.isSqlflow(text) 209 || MetadataReader.isSqlflowSharded(text)) { 210 // the analyzer would read the input as a metadata export, not as SQL text the document can cite 211 throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "input " + i 212 + " is a metadata export, not SQL text; give metadata through ProducerConfig.metadata"); 213 } 214 texts[i] = text; 215 } 216 // A statement with a syntax error leaves its whole unit unresolved; analyse again with it blanked (plan 06, 217 // Section 3.8). The rerun happens only when a statement failed to parse. 218 List<ParseFailures.Failed> failed = new ArrayList<ParseFailures.Failed>(); 219 gudusoft.gsqlparser.lineage.v2.shape.Shape projected = null; 220 IdentityPolicy policy = null; 221 int analyses = 0; 222 for (int analysis = 1; projected == null; analysis++) { 223 DataFlowAnalyzer analyzer = new DataFlowAnalyzer(infos(inputs, texts), option); 224 SqllrObservations observations = new SqllrObservations(true); 225 try { 226 clearThreadState(); 227 // Always a private catalog: without one the analyzer reads the DDL of every input into its catalog 228 // before any statement is resolved, so a CREATE after a statement would decide how the statement's 229 // names resolve. The parse registers each declaration where it stands instead. 230 analyzer.setSqlEnv(config.getMetadata() != null 231 ? MetadataSnapshot.copy(config.getMetadata().getCatalog(), config) 232 : MetadataSnapshot.empty(config.getVendor())); 233 analyzer.setSqllrObservations(observations); 234 if (prepare != null) { 235 prepare.accept(observations); 236 } 237 // what the producer reads is projected when the analysis freezes (plan 09, slice 1b): the recorder, 238 // and the producer's own copy of the supplied catalog 239 final gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder[] shape = 240 new gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder[1]; 241 final gudusoft.gsqlparser.sqlenv.TSQLEnv supplied = config.getMetadata() == null ? null 242 : MetadataSnapshot.copy(config.getMetadata().getCatalog(), config); 243 observations.setFreezeHook(o -> shape[0] = 244 gudusoft.gsqlparser.lineage.v2.shape.ShapeBuilder.freeze(o, supplied)); 245 analyzer.generateDataFlow(); 246 checkTexts(texts, observations); 247 if (shape[0] == null) { 248 throw new IllegalStateException("SQL-LR: the analysis was not projected: " 249 + observations.getFreezeHookFailure(), observations.getFreezeHookFailure()); 250 } 251 List<ParseFailures.Failed> found = analysis < ParseFailures.MAX_ANALYSES 252 ? ParseFailures.collect(shape[0].shape().observations(), texts) 253 : Collections.<ParseFailures.Failed>emptyList(); 254 if (found.isEmpty()) { 255 // the renderings are taken now, after the analysis and before dispose, as the producer took them 256 boolean keep = capture != null && capture.shapeCheck != null; 257 projected = shape[0].complete(keep); 258 policy = IdentityPolicy.of(config.getVendor(), analyzer.getIdentifierProfile()); 259 analyses = analysis; 260 if (capture != null) { 261 capture(capture, inputs, texts, failed, analysis, analyzer, config, observations); 262 capture.shape = projected; 263 if (keep) { 264 capture.shapeCheck.accept(shape[0]); 265 } 266 } 267 shape[0].release(); 268 } else { 269 failed.addAll(found); 270 texts = ParseFailures.blank(texts, found); 271 } 272 } finally { 273 clearThreadState(); 274 observations.close(); 275 analyzer.dispose(); 276 } 277 } 278 // the document is built from the shape alone: the analysis is disposed 279 DocumentBuilder builder = new DocumentBuilder(config, policy, inputs, projected, failed); 280 if (analyses > 1) { 281 builder.notes().add("analysed " + analyses + " times: " + failed.size() 282 + " statement(s) that did not parse were blanked"); 283 } 284 ProducerResult result = capture != null && capture.buildOnAnotherThread ? buildElsewhere(builder) 285 : builder.build(); 286 if (capture != null) { 287 capture.result = result; 288 owners(capture, projected.observations(), builder); 289 } 290 return result; 291 } 292 293 /** Tests only: {@link DocumentBuilder#build()} on a thread of its own, its failure rethrown here. */ 294 private static ProducerResult buildElsewhere(final DocumentBuilder builder) { 295 final ProducerResult[] result = new ProducerResult[1]; 296 final Throwable[] failure = new Throwable[1]; 297 Thread t = new Thread(() -> { 298 try { 299 result[0] = builder.build(); 300 } catch (Throwable e) { 301 failure[0] = e; 302 } 303 }, "sqllr-document"); 304 t.start(); 305 try { 306 t.join(); 307 } catch (InterruptedException e) { 308 Thread.currentThread().interrupt(); 309 throw new IllegalStateException("SQL-LR: interrupted while the document was built", e); 310 } 311 if (failure[0] instanceof RuntimeException) { 312 throw (RuntimeException) failure[0]; 313 } 314 if (failure[0] instanceof Error) { 315 throw (Error) failure[0]; 316 } 317 return result[0]; 318 } 319 320 /** Runs on the analysis thread, before {@code dispose()}: the legacy model still has its thread context. */ 321 private static void capture(Capture capture, List<SqllrInput> inputs, String[] texts, 322 List<ParseFailures.Failed> failed, int analysis, DataFlowAnalyzer analyzer, ProducerConfig config, 323 SqllrObservations observations) { 324 for (SqllrInput input : inputs) { 325 capture.inputTexts.add(input.getText()); 326 } 327 Collections.addAll(capture.analysedTexts, texts); 328 for (ParseFailures.Failed f : failed) { 329 capture.blanked.add(new int[] {f.getUnitIndex(), f.getStart(), f.getEnd()}); 330 } 331 capture.analyses = analysis; 332 gudusoft.gsqlparser.dlineage.dataflow.model.xml.dataflow legacy = analyzer.getDataFlow(); 333 if (legacy != null) { 334 try { 335 capture.legacyXml = gudusoft.gsqlparser.dlineage.util.XML2Model.saveXML(legacy); 336 } catch (Exception e) { 337 throw new IllegalStateException("SQL-LR capture: the legacy model did not serialize", e); 338 } 339 capture.legacyJson = gudusoft.gsqlparser.util.json.JSON.toJSONString( 340 DataFlowAnalyzer.getSqlflowJSONModel(legacy, config.getVendor())); 341 } 342 capture.snapshot = observations.getSnapshot(); 343 capture.snapshotFailure = observations.getSnapshotFailure(); 344 } 345 346 /** What the builder decided, read from the shape it was built from. */ 347 private static void owners(Capture capture, gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations projected, 348 DocumentBuilder builder) { 349 capture.unitTexts.putAll(builder.unitTexts); 350 capture.ledgerRelations.putAll(builder.ledger().getRelations()); 351 capture.ledgerRelationStatements.putAll(builder.ledger().getRelationStatements()); 352 capture.ledgerElementEvents.putAll(builder.ledger().getElementEvents()); 353 capture.ledgerReferenceEvents.putAll(builder.ledger().getReferenceEvents()); 354 capture.ledgerGraphed.addAll(builder.ledger().getGraphed()); 355 capture.ledgerEventEdges.putAll(builder.ledger().getEventEdges()); 356 capture.ledgerEventDiagnostics.putAll(builder.ledger().getEventDiagnostics()); 357 capture.ledgerEventNodes.putAll(builder.ledger().getEventNodes()); 358 // the builder knows the mirrors (plan 09, slice 1b) 359 if (projected.getFrozenRelations() != null) { 360 for (gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations.FrozenRelation f 361 : projected.getFrozenRelations()) { 362 gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations.ElementRecord target = 363 builder.targetOf(f.getRelation()); 364 if (target != null) { 365 DocumentBuilder.Stmt owner = builder.publisher(target.getStatement()); 366 capture.relationPublishers.put(Long.valueOf(f.getRelation().getId()), 367 Long.valueOf(owner == null ? -1 : owner.ref)); 368 } 369 } 370 } 371 for (gudusoft.gsqlparser.lineage.v2.shape.SqllrObservations.ElementRecord record : projected.getElements()) { 372 if (!record.isTarget() || record.getRelation() == null) { 373 continue; 374 } 375 DocumentBuilder.Stmt owner = builder.publisher(record.getStatement()); 376 if (owner != null && !capture.relationOwners.containsKey(record.getRelation().getId())) { 377 capture.relationOwners.put(record.getRelation().getId(), owner.ref); 378 } 379 } 380 } 381 382 private static SqlInfo[] infos(List<SqllrInput> inputs, String[] texts) { 383 SqlInfo[] infos = new SqlInfo[texts.length]; 384 for (int i = 0; i < infos.length; i++) { 385 SqllrInput input = inputs.get(i); 386 SqlInfo info = new SqlInfo(); 387 info.setSql(texts[i]); 388 // The analyzer reads the file a SqlInfo names when its text is empty or white space only (an empty input, 389 // or a unit whose statements were all blanked). A unit is its text: its logical name is an identity, never 390 // a path to read, so such a unit gets no name (plan 09, slice 0). 391 info.setFileName(gudusoft.gsqlparser.util.SQLUtil.isEmpty(texts[i]) ? null 392 : input.getLogicalName() != null ? input.getLogicalName() : "input-" + i); 393 infos[i] = info; 394 } 395 return infos; 396 } 397 398 /** 399 * Analysis state that {@code ModelBindingManager.remove()} leaves on the thread (dlineage CLAUDE.md, Concurrency): 400 * an earlier analysis on this thread, or the previous analysis of this production, must not reach this one. 401 */ 402 private static void clearThreadState() { 403 ModelBindingManager.removeGlobalOraclePackage(); 404 ModelBindingManager.setSyntheticStmtContext(false); 405 if (ModelBindingManager.get() != null) { 406 ModelBindingManager.removeGlobalProcedure(); 407 } 408 } 409 410 /** Every published position is an offset into the input text: the analysis must have parsed exactly that text. */ 411 private static void checkTexts(String[] texts, SqllrObservations observations) { 412 for (SqllrObservations.UnitRecord unit : observations.getUnits()) { 413 int i = unit.getUnitIndex(); 414 if (i < 0 || i >= texts.length || !texts[i].equals(unit.getParsedText())) { 415 throw new IllegalStateException("SQL-LR: the analysis parsed a text that is not input " + i); 416 } 417 } 418 } 419}