Skip to content

Commit c38ae3d

Browse files
authored
fix(core): add debug probe SQL for join equivalence (#956)
1 parent 1d796cf commit c38ae3d

5 files changed

Lines changed: 478 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"rawsql-ts": patch
3+
---
4+
5+
Add debug-probe SQL output for JOIN-equivalence predicate investigation and allow LEFT JOIN preserved-side predicates to probe nullable-side inputs.

packages/core/src/transformers/ConditionOptimization.ts

Lines changed: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
TableSource
2727
} from "../models/Clause";
2828
import { SelectQueryParser } from "../parsers/SelectQueryParser";
29+
import { ValueParser } from "../parsers/ValueParser";
2930
import { rewriteValueComponentWithColumnResolver } from "../utils/ValueComponentRewriter";
3031
import {
3132
ParameterConditionPlacementOptimizer,
@@ -46,6 +47,10 @@ import {
4647
ConditionDeduplicationApplied,
4748
ConditionDeduplicationOptimizer
4849
} from "./ConditionDeduplicationOptimizer";
50+
import {
51+
analyzePredicateReachability,
52+
PredicateReachabilityResult
53+
} from "./PredicateReachabilityAnalyzer";
4954
import {
5055
collectSupportedOptionalConditionBranches,
5156
OptionalConditionPruningParameters,
@@ -147,6 +152,12 @@ export interface ConditionOptimizationSourceFilterProbe {
147152
predicate: string;
148153
suggestedSql: string;
149154
reason: string;
155+
relation?: "source_filter" | "join_equivalence";
156+
targetPredicate?: string;
157+
targetScope?: {
158+
kind: "scope" | "cte" | "table" | "subquery" | "source";
159+
name: string;
160+
};
150161
}
151162

152163
export interface ConditionOptimizationSkippedProbe {
@@ -166,6 +177,16 @@ export interface ConditionOptimizationDiagnostics {
166177
*/
167178
probes: readonly ConditionOptimizationSourceFilterProbe[];
168179
skippedProbes: readonly ConditionOptimizationSkippedProbe[];
180+
/**
181+
* API output shape review: keep result.sql/result.query as the production-safe
182+
* rewrite output and expose debugSql/debugQuery only for diagnostic pipelines.
183+
*
184+
* Debug-only probe SQL is additive: result.sql/result.query remain the safe-only
185+
* production rewrite output, while debugSql/debugQuery include investigation
186+
* probes that callers can feed into lineage/debug surfaces.
187+
*/
188+
debugSql?: string;
189+
debugQuery?: SelectQuery | null;
169190
}
170191

171192
export interface ConditionOptimizationResult {
@@ -609,6 +630,121 @@ const collectConditionOptimizationDiagnostics = (
609630
return { probes, skippedProbes };
610631
};
611632

633+
const makeReachabilityProbeSuggestedSql = (
634+
sourceName: string,
635+
predicate: string | undefined,
636+
options: SqlComponentFormatOptions
637+
): string => {
638+
if (!predicate) {
639+
return `select * from ${sourceName}`;
640+
}
641+
642+
const sourceLocalPredicate = rewriteValueComponentWithColumnResolver(
643+
ValueParser.parse(predicate),
644+
reference => new ColumnReference(null, reference.column.name)
645+
);
646+
return sourceLocalPredicate
647+
? `select * from ${sourceName} where ${formatSqlComponent(sourceLocalPredicate, options)}`
648+
: `select * from ${sourceName}`;
649+
};
650+
651+
const collectJoinEquivalenceProbeDiagnostics = (
652+
reachability: PredicateReachabilityResult,
653+
options: SqlComponentFormatOptions
654+
): Pick<ConditionOptimizationDiagnostics, "probes" | "skippedProbes"> => {
655+
const probes: ConditionOptimizationSourceFilterProbe[] = [];
656+
const skippedProbes: ConditionOptimizationSkippedProbe[] = [];
657+
658+
for (const predicate of reachability.predicates) {
659+
for (const target of predicate.probeTargets) {
660+
if (target.relation !== "join_equivalence") {
661+
continue;
662+
}
663+
probes.push({
664+
kind: "source_filter_probe",
665+
source: target.target.name,
666+
sourceAlias: target.target.name,
667+
predicate: target.predicateSql,
668+
suggestedSql: makeReachabilityProbeSuggestedSql(target.target.name, target.targetPredicateSql, options),
669+
reason: "predicate inferred through JOIN equivalence for debug probing",
670+
relation: "join_equivalence",
671+
...(target.targetPredicateSql ? { targetPredicate: target.targetPredicateSql } : {}),
672+
targetScope: target.target
673+
});
674+
}
675+
676+
for (const blocked of predicate.blocked) {
677+
if (blocked.relation !== "join_equivalence") {
678+
continue;
679+
}
680+
const [, ...nameParts] = blocked.scopeId.split(":");
681+
const source = nameParts.join(":") || undefined;
682+
skippedProbes.push({
683+
kind: "source_filter_probe_skipped",
684+
source,
685+
sourceAlias: source,
686+
predicate: predicate.predicateSql,
687+
code: blocked.code,
688+
reason: blocked.reason
689+
});
690+
}
691+
}
692+
693+
return { probes, skippedProbes };
694+
};
695+
696+
const findDebugCte = (
697+
query: SimpleSelectQuery,
698+
name: string
699+
) => {
700+
const normalized = normalizeIdentifier(name);
701+
const matches = (query.withClause?.tables ?? [])
702+
.filter(table => normalizeIdentifier(table.getSourceAliasName()) === normalized);
703+
return matches.length === 1 ? matches[0]! : null;
704+
};
705+
706+
const buildDebugProbeQuery = (
707+
baseQuery: SelectQuery | null,
708+
reachability: PredicateReachabilityResult,
709+
options: SqlComponentFormatOptions
710+
): SelectQuery | null => {
711+
if (!(baseQuery instanceof SimpleSelectQuery)) {
712+
return null;
713+
}
714+
715+
const applicableTargets = reachability.predicates.flatMap(predicate => predicate.probeTargets)
716+
.filter(target =>
717+
target.relation === "join_equivalence"
718+
&& Boolean(target.targetPredicateSql)
719+
&& target.target.kind === "cte"
720+
);
721+
if (applicableTargets.length === 0) {
722+
return null;
723+
}
724+
725+
const debugQuery = SelectQueryParser.parse(formatSqlComponent(baseQuery, options));
726+
if (!(debugQuery instanceof SimpleSelectQuery)) {
727+
return null;
728+
}
729+
730+
let applied = false;
731+
for (const target of applicableTargets) {
732+
const cte = findDebugCte(debugQuery, target.target.name);
733+
if (!cte || !(cte.query instanceof SimpleSelectQuery) || !target.targetPredicateSql) {
734+
continue;
735+
}
736+
cte.query.appendWhereRaw(target.targetPredicateSql);
737+
applied = true;
738+
}
739+
740+
if (!applied) {
741+
return null;
742+
}
743+
744+
const dedupePhase = new ConditionDeduplicationOptimizer().optimize(debugQuery, options);
745+
return dedupePhase.query ?? debugQuery;
746+
};
747+
612748
const isAbsentOptionalValue = (
613749
parameters: OptionalConditionPruningParameters,
614750
parameterName: string
@@ -1050,6 +1186,54 @@ const makePhaseSummary = (
10501186
errorCount: counts.errorCount
10511187
});
10521188

1189+
const getAppliedConditionSql = (applied: ConditionOptimizationApplied): string | undefined => {
1190+
if ("predicateSql" in applied) {
1191+
return applied.predicateSql;
1192+
}
1193+
if ("conditionSql" in applied) {
1194+
return applied.conditionSql;
1195+
}
1196+
return undefined;
1197+
};
1198+
1199+
const normalizeDiagnosticPredicate = (
1200+
predicate: string,
1201+
options: SqlComponentFormatOptions
1202+
): string => {
1203+
try {
1204+
return formatSqlComponent(ValueParser.parse(predicate), options)
1205+
.replace(/\s+/g, " ")
1206+
.trim()
1207+
.toLowerCase();
1208+
} catch {
1209+
return predicate
1210+
.replace(/\s+/g, " ")
1211+
.trim()
1212+
.toLowerCase();
1213+
}
1214+
};
1215+
1216+
const filterSkippedProbesAlreadyApplied = (
1217+
skippedProbes: readonly ConditionOptimizationSkippedProbe[],
1218+
applied: readonly ConditionOptimizationApplied[],
1219+
options: SqlComponentFormatOptions
1220+
): ConditionOptimizationSkippedProbe[] => {
1221+
const appliedPredicates = new Set(
1222+
applied
1223+
.map(item => getAppliedConditionSql(item))
1224+
.filter((predicate): predicate is string => Boolean(predicate))
1225+
.map(predicate => normalizeDiagnosticPredicate(predicate, options))
1226+
);
1227+
1228+
if (appliedPredicates.size === 0) {
1229+
return [...skippedProbes];
1230+
}
1231+
1232+
return skippedProbes.filter(probe => probe.code !== "NESTED_QUERY_UNSUPPORTED" || !appliedPredicates.has(
1233+
normalizeDiagnosticPredicate(probe.predicate, options)
1234+
));
1235+
};
1236+
10531237
const runConditionOptimization = (
10541238
input: ConditionOptimizationInput,
10551239
options: ConditionOptimizationOptions,
@@ -1061,7 +1245,13 @@ const runConditionOptimization = (
10611245
// Run semantic SSSQL handling before generic placement phases so optional
10621246
// branches remain owned by SSSQL even if later phases grow broader support.
10631247
const sssqlPhase = runSssqlOptionalConditionPhase(input, { ...options, dryRun });
1064-
const diagnostics = collectConditionOptimizationDiagnostics(sssqlPhase.query, options);
1248+
const sourceDiagnostics = collectConditionOptimizationDiagnostics(sssqlPhase.query, options);
1249+
const reachability = sssqlPhase.query
1250+
? analyzePredicateReachability(sssqlPhase.query, { ...options, cloneInput: false })
1251+
: null;
1252+
const reachabilityDiagnostics = reachability
1253+
? collectJoinEquivalenceProbeDiagnostics(reachability, options)
1254+
: { probes: [], skippedProbes: [] };
10651255
const parameterOptimizer = new ParameterConditionPlacementOptimizer();
10661256
const parameterInput = reuseOwnedModel
10671257
? sssqlPhase.query ?? sssqlPhase.sql
@@ -1103,6 +1293,20 @@ const runConditionOptimization = (
11031293
const finalSql = dedupePhase.applied.length > 0 && dedupePhase.query
11041294
? formatSqlComponent(dedupePhase.query, options)
11051295
: staticPhase.sql;
1296+
const applied = [...sssqlPhase.applied, ...parameterApplied, ...staticApplied, ...dedupeApplied];
1297+
const debugQuery = reachability
1298+
? buildDebugProbeQuery(dedupePhase.query, reachability, options)
1299+
: null;
1300+
const debugSql = debugQuery ? formatSqlComponent(debugQuery, options) : undefined;
1301+
const diagnostics: ConditionOptimizationDiagnostics = {
1302+
probes: [...sourceDiagnostics.probes, ...reachabilityDiagnostics.probes],
1303+
skippedProbes: filterSkippedProbesAlreadyApplied(
1304+
[...sourceDiagnostics.skippedProbes, ...reachabilityDiagnostics.skippedProbes],
1305+
applied,
1306+
options
1307+
),
1308+
...(debugSql ? { debugSql, debugQuery } : {})
1309+
};
11061310

11071311
return {
11081312
ok: errors.length === 0,
@@ -1134,7 +1338,7 @@ const runConditionOptimization = (
11341338
errorCount: 0
11351339
})
11361340
],
1137-
applied: [...sssqlPhase.applied, ...parameterApplied, ...staticApplied, ...dedupeApplied],
1341+
applied,
11381342
skipped: [...sssqlPhase.skipped, ...parameterSkipped, ...staticSkipped],
11391343
warnings,
11401344
errors,

0 commit comments

Comments
 (0)