Skip to content

Commit c2cd1d1

Browse files
[2.43] fix: Fixes in analytics SQL engine for ClickHouse compatibility [DHIS2-21417]
* fix: Fixes in analytics SQL engine for ClickHouse compatibility [DHIS2-21417] (#23810) * fix: [DHIS2-21418] lowercase period column names in event SQL emission PeriodTypeEnum.getName() returns display-cased names (e.g. "Monthly"); the analytics tables are created with lowercase period columns. Postgres folds unquoted identifiers, Doris is case-insensitive, ClickHouse is not. Lowercase with Locale.ROOT at every emission point so the reference matches the table column on every engine. * fix: [DHIS2-21419] lowercase program UID in analytics_event/_enrollment table names DHIS2 UIDs aremixed-case but the analytics tables themselves are created with lowercased names (analytics_event_iphinat79uw). Multiple emission sites concatenated "analytics_event_" + program.getUid() without lowercasing. Invisible on Postgres (case-folded) and Doris (lower_case_table_names=1), broken on ClickHouse (UNKNOWN_TABLE). Apply .toLowerCase() at every emission site. * fix: [DHIS2-21421] emit JOIN form for date-period-structure lookup on ClickHouse DateFieldPeriodBucketColumnResolver already supports two emission shapes for the `analytics_rs_dateperiodstructure` lookup, switched on sqlBuilder.useJoinForDatePeriodStructureLookup(): a correlated scalar subquery (Postgres) or a LEFT JOIN (Doris). ClickHouse's analyzer rejects the correlated form ("Resolve identifier 'eb.lastupdated' from parent scope only supported for constants and CTE"), so override the flag to true. The JOIN form works on every engine, so this is a strict improvement with no regression risk on Postgres/Doris. * fix: [DHIS2-21422] identity-alias prefixed enrollment column in aggregate CTEs ClickHouse's analyzer keeps the original table prefix attached to a projected column when no explicit output alias is given. So a CTE projecting ax.enrollment produces a column the analyzer still binds to ax - the outer query's eb.enrollment from the enrollment_aggr_base CTE alias is then unresolvable. Postgres and Doris implicitly drop the prefix and re-scope under the CTE alias. Add explicit identity aliases at two emission sites in JdbcEnrollmentAnalyticsManager: the enrollment_aggr_base CTE projection and the inline derived-table evf event-date subquery. The alias re-binds the column under the CTE / derived-table alias on ClickHouse and is a no-op for Postgres/Doris. * fix: [DHIS2-21423] NULL-safe date cast for analytics joins ClickHouse's toDate(NULL) and CAST(NULL AS Date) raise CANNOT_INSERT_NULL_IN_ORDINARY_COLUMN, and toDateOrNull only accepts String input. Aggregate queries that join to analytics_rs_dateperiodstructure on a nullable data-element column (e.g. cast(ax."<deUid>" as date)) crash. Introduce AnalyticsSqlBuilder.castAsDate(String). Default emits ANSI `cast(... as date)` - correct for Postgres and Doris. ClickHouse overrides to toDateOrNull(toString(...)), which is both NULL-safe and accepts every input type the cast might receive (Date / DateTime / DateTime64 / String / Nullable variants). JdbcEventAnalyticsManager's hasTimeField LEFT JOIN now routes through sqlBuilder.castAsDate. Output for Postgres/Doris is byte-identical. * fix: [DHIS2-21428] align dimension columns at SELECT for ClickHouse Two fixes for the same family of case-sensitivity issue: 1) AggregatedRowBuilder.addDimensionData lowercases the dimension name with Locale.ROOT before the result-set lookup. Period-column SQL emission was already lowercased; the row builder still asked the result set for "Quarterly" instead of "quarterly", which Postgres/MySQL JDBC normalised silently but ClickHouse JDBC rejected with InvalidResultSetAccessException. 2) Add explicit "as <col>" alias to dimension projections in the SELECT context so the result-set column has a canonical name regardless of how the engine reports the underlying expression. ClickHouse JDBC retains the table prefix on unaliased prefixed columns (ax.uidlevel2) in result-set metadata; Postgres/MySQL strip it. * fix: [DHIS2-21430] generate legend-set columns on ClickHouse via inline CASE JdbcEventAnalyticsTableManager.getColumnFromDataElementWithLegendSet early-returned an empty list on engines without correlated-subquery support (ClickHouse). The companion <deUid>_<lsUid> column was never created in the ClickHouse analytics tables, but the query path still emitted SQL referring to it — every aggregate query grouped or filtered by legend bucket failed with UNKNOWN_IDENTIFIER. Replace the early-return with a per-row CASE expression built from the static legend ranges. Same final value populated into the column, no correlated subquery, ClickHouse-friendly. Postgres and Doris are unaffected: supportsCorrelatedSubquery() returns true on both, so the existing correlated-subquery emission path runs unchanged. * fix: [DHIS2-21431] populate OU-name support column on ClickHouse via JOIN Populate the <deUid>_name support column on ClickHouse via a LEFT JOIN to organisationunit instead of a correlated subquery (which ClickHouse rejects). The column was previously not created at all, causing UNKNOWN_IDENTIFIER errors in aggregate queries that group by an org-unit-typed data element. * Fix formatting * Fix failing unit test * More Unit Test fixes (lower case program) * refactor: [DHIS2-21420] centralise analytics_event/_enrollment table names Introduce AnalyticsTableNames in dhis-support-sql with eventTable(Program) and enrollmentTable(Program) helpers. Replace every inline "analytics_event_" + program.getUid().toLowerCase() and equivalent "analytics_enrollment_" + ... concatenation with a call through the helper. * fix: [DHIS2-21422] identity-alias prefixed enrollment column in PI value CTE Same ClickHouse-analyzer issue as the prior fix in JdbcEnrollmentAnalyticsManager: a CTE projecting `subax.enrollment` without an explicit output alias keeps the column bound to `subax` in ClickHouse's scope, so the outer query's `<alias>.enrollment` reference cannot be resolved. Postgres and Doris re-scope the column under the CTE alias implicitly. Add an explicit identity alias on the projection in DefaultProgramIndicatorSubqueryBuilder so the column is re-bound under the CTE alias on ClickHouse. No-op for Postgres/Doris. * remove doc for tracking issues * fix: [DHIS2-21441] fix rejected correlated scalar subqueries in Events query * fix: [DHIS2-21486] fix event PI queries by replacing correlated subqueries with event-keyed CTE joins * fix: [DHIS2-21487] Fix enrollment stage display-name projections * fix: [DHIS2-21488] fix relationship count CTE SQL for enrollment PIs * fix: [DHIS2-21493] Fix ClickHouse dateDiff literal casting * fix: [DHIS2-21495] Fix option-set stage CTE value projection * Use correlated subqueries when engine supports it * fix: scope ClickHouse event PI CTEs to candidate events Add an internal event_pi_candidates CTE for EVENT analytics queries when the database does not support correlated subqueries and EVENT program-indicator CTEs are generated. The candidate CTE mirrors the outer event query scope, including base filters such as period, org unit, stage, and non-PI query item filters. EVENT PI CTEs now read from this candidate source instead of scanning the full analytics event table. PI filters stay outside the candidate CTE to avoid circular SQL dependencies. Postgres and other correlated-subquery paths remain unchanged. Enrollment PI CTEs keep their existing source behavior. * fix: keep event query enrollment PIs on CTE path Narrow the Event analytics correlated-subquery fallback so it only applies to EVENT-type program indicators. ENROLLMENT-type program indicators used from the Event endpoint still need the CTE path because it expands generated placeholders such as `FUNC_CTE_VAR`, `__PSDE_CTE_PLACEHOLDER__`, and `__D2FUNC__`. * refactor: [DHIS2-21488] replace relationship-count regex with d2:relationshipCount placeholder pipeline * fix: use ClickHouse age() for year/month/week date differences ClickHouse dateDiff() counts boundary crossings, so program indicators using d2:yearsBetween, d2:monthsBetween and d2:weeksBetween returned values that differed from PostgreSQL, which counts completed units via age(). For example dateDiff('year', '2023-12-31', '2024-01-01') is 1 where PostgreSQL reports 0 completed years. Switch the years, months and weeks cases of ClickHouseSqlBuilder.dateDifference to age() (available since ClickHouse 23.1), keeping the same operand order. Days and minutes stay on dateDiff() because PostgreSQL computes them with date subtraction and elapsed time, which already match. * fix: normalize empty text to NULL in ClickHouse enrollment aggregate CTEs Enrollment aggregate queries with program-stage data element dimensions returned more groups on ClickHouse than on Postgres. ClickHouse analytics tables store empty strings for absent text values where Postgres stores NULL, so GROUP BY split empty and NULL into separate buckets. Add AnalyticsSqlBuilder.nullIfEmpty, a no-op by default and overridden in `ClickHouseAnalyticsSqlBuilder` to wrap the column in `nullif(column, '')`. * fix: make ClickHouse safeConcat null-safe for analytics display names * fix: align ClickHouse event query columns and join semantics with Postgres * fix: treat ClickHouse empty-string coordinates as null in coordinatesOnly filter * fix: fold ClickHouse empty-string text dimensions to NULL in event aggregate * fix: alias wrapped ClickHouse aggregate text columns to keep result-set label * fix: join ClickHouse relationship-count CTE by alias, not registry key
1 parent 8900992 commit c2cd1d1

57 files changed

Lines changed: 3778 additions & 470 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

dhis-2/dhis-api/src/main/java/org/hisp/dhis/program/ProgramIndicatorService.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,28 @@ String getAnalyticsSql(
213213
Date endDate,
214214
String tableAlias);
215215

216+
/**
217+
* Same as {@link #getAnalyticsSql(String, DataType, ProgramIndicator, Date, Date, String)} but
218+
* leaves the {@code d2:relationshipCount} placeholder unexpanded so callers can apply a
219+
* dialect-specific expansion (e.g. a {@code LEFT JOIN} on engines that do not support correlated
220+
* subqueries).
221+
*
222+
* @param expression the expression.
223+
* @param dataType the data type to return.
224+
* @param programIndicator the program indicator to evaluate.
225+
* @param startDate the start date.
226+
* @param endDate the end date.
227+
* @param tableAlias use this table alias for expression returning a inner query
228+
* @return the SQL string with {@code __D2RELCNT__(uid='...')__} placeholders intact.
229+
*/
230+
String getAnalyticsSqlDeferRelationshipCount(
231+
String expression,
232+
DataType dataType,
233+
ProgramIndicator programIndicator,
234+
Date startDate,
235+
Date endDate,
236+
String tableAlias);
237+
216238
/**
217239
* Gets the analytics SQL clause of an expression. The SQL does not substitute null values.
218240
*

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/OrgUnitField.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,10 @@ private String ouQuote(String tableAlias, String col, boolean noColumnAlias) {
230230
+ ((noColumnAlias) ? "" : " as " + col);
231231
}
232232

233-
return sqlBuilder.quote(tableAlias, col);
233+
// Always add the column alias when one is requested. Without it, JDBC drivers that retain the
234+
// table prefix in result-set metadata (e.g. ClickHouse) report the column as
235+
// "<tableAlias>.<col>", and downstream lookups by bare column name fail.
236+
return sqlBuilder.quote(tableAlias, col) + ((noColumnAlias) ? "" : " as " + col);
234237
}
235238

236239
@Override

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/CteContext.java

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,15 @@
5252
*/
5353
@Slf4j
5454
public class CteContext {
55+
public static final String EVENT_PROGRAM_INDICATOR_CANDIDATES = "event_pi_candidates";
56+
5557
private final Map<String, CteDefinition> cteDefinitions = new LinkedHashMap<>();
5658

5759
/** The type of analytics query being executed. This can be either EVENT or ENROLLMENT. */
5860
@Getter private final EndpointItem endpointItem;
5961

62+
@Getter private String eventProgramIndicatorSourceTable;
63+
6064
public CteDefinition getDefinitionByItemUid(String itemUid) {
6165
return cteDefinitions.get(itemUid);
6266
}
@@ -100,6 +104,28 @@ public void addCte(
100104
int offset,
101105
boolean isRowContext,
102106
boolean hasFilter) {
107+
addCte(programStage, item, cteDefinition, offset, isRowContext, hasFilter, false);
108+
}
109+
110+
/**
111+
* Adds a CTE definition to the context.
112+
*
113+
* @param programStage The program stage
114+
* @param item The query item
115+
* @param cteDefinition The CTE definition (the SQL query)
116+
* @param offset The calculated offset
117+
* @param isRowContext Whether the CTE is a row context
118+
* @param hasFilter Whether the query item has a filter requiring non-null CTE values
119+
* @param hasValueName Whether the CTE exposes the display-name column as value_name
120+
*/
121+
public void addCte(
122+
ProgramStage programStage,
123+
QueryItem item,
124+
String cteDefinition,
125+
int offset,
126+
boolean isRowContext,
127+
boolean hasFilter,
128+
boolean hasValueName) {
103129
String key = computeKey(item);
104130
if (cteDefinitions.containsKey(key)) {
105131
cteDefinitions.get(key).getOffsets().add(offset);
@@ -111,7 +137,8 @@ public void addCte(
111137
cteDefinition,
112138
offset,
113139
isRowContext,
114-
hasFilter);
140+
hasFilter,
141+
hasValueName);
115142
cteDefinitions.put(key, cteDef);
116143
}
117144
}
@@ -154,16 +181,21 @@ public void addExistsCte(ProgramStage programStage, QueryItem item, String cteDe
154181
* @param cteDefinition The CTE definition (the SQL query)
155182
* @param functionRequiresCoalesce Whether the function requires to be "wrapped" in coalesce to
156183
* avoid null values (e.g. avg, sum)
184+
* @param joinColumn The column exposed by the CTE for joining it back to the outer query
157185
*/
158186
public void addProgramIndicatorCte(
159-
ProgramIndicator programIndicator, String cteDefinition, boolean functionRequiresCoalesce) {
187+
ProgramIndicator programIndicator,
188+
String cteDefinition,
189+
boolean functionRequiresCoalesce,
190+
String joinColumn) {
160191
cteDefinitions.put(
161192
programIndicator.getUid(),
162193
CteDefinition.forProgramIndicator(
163194
programIndicator.getUid(),
164195
programIndicator.getAnalyticsType(),
165196
cteDefinition,
166-
functionRequiresCoalesce));
197+
functionRequiresCoalesce,
198+
joinColumn));
167199
}
168200

169201
/**
@@ -258,12 +290,43 @@ public void addD2FunctionCte(String key, CteDefinition cteDefinition) {
258290
}
259291
}
260292

293+
/**
294+
* Registers a {@link CteDefinition.CteType#D2_RELATIONSHIP_COUNT} CTE. No-op if a CTE with the
295+
* same key was already registered, so multiple PIs (or multiple references inside one PI) for the
296+
* same relationship type share a single CTE.
297+
*/
298+
public void addRelationshipCountCte(String key, CteDefinition cteDefinition) {
299+
if (cteDefinition != null
300+
&& key != null
301+
&& cteDefinition.getCteType() == CteDefinition.CteType.D2_RELATIONSHIP_COUNT) {
302+
cteDefinitions.putIfAbsent(key, cteDefinition);
303+
} else {
304+
log.warn("Attempted to add invalid relationship count CTE definition for key: {}", key);
305+
}
306+
}
307+
261308
public void addShadowCte(String tableName, String sql, CteDefinition.CteType cteType) {
262309
// Use a simple CteDefinition for shadow CTEs
263310
CteDefinition shadowCte = CteDefinition.forShadowTable(tableName, sql, cteType);
264311
cteDefinitions.put(tableName, shadowCte);
265312
}
266313

314+
public void useEventProgramIndicatorCandidateSource() {
315+
eventProgramIndicatorSourceTable = EVENT_PROGRAM_INDICATOR_CANDIDATES;
316+
}
317+
318+
public void addEventProgramIndicatorCandidatesCte(String sql) {
319+
addShadowCte(
320+
EVENT_PROGRAM_INDICATOR_CANDIDATES,
321+
sql,
322+
CteDefinition.CteType.EVENT_PROGRAM_INDICATOR_CANDIDATES);
323+
}
324+
325+
public boolean hasEventProgramIndicatorCtes() {
326+
return cteDefinitions.values().stream()
327+
.anyMatch(def -> def.getCteType() == CteDefinition.CteType.PROGRAM_INDICATOR_EVENT);
328+
}
329+
267330
public CteDefinition getBaseAggregatedCte() {
268331
return cteDefinitions.get(ENROLLMENT_AGGR_BASE);
269332
}

dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/common/CteDefinition.java

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,12 @@ public enum CteType {
6868
PROGRAM_STAGE_DATE_ELEMENT(10),
6969
/** CTE replacing a d2:function(...) subquery (like d2:countIfValue). */
7070
D2_FUNCTION(10),
71+
/** Per-trackedEntity relationship count CTE backing {@code d2:relationshipCount(...)}. */
72+
D2_RELATIONSHIP_COUNT(10),
7173
/** CTE for checking existence (rowContext=true). */
7274
EXISTS(10),
75+
/** Candidate event rows used as input for EVENT program indicator CTEs. */
76+
EVENT_PROGRAM_INDICATOR_CANDIDATES(4),
7377
/** Special CTE for pre-aggregated enrollments. */
7478
TOP_ENROLLMENTS(1),
7579
SHADOW_ENROLLMENT_TABLE(2),
@@ -110,6 +114,9 @@ public enum CteType {
110114
/** Whether the query item has a filter that requires non-null values from this CTE */
111115
private final boolean hasFilter;
112116

117+
/** Whether the CTE exposes a display-name column as value_name. */
118+
private final boolean hasValueName;
119+
113120
private CteDefinition(
114121
String itemId,
115122
String programStageUid,
@@ -123,7 +130,8 @@ private CteDefinition(
123130
String joinColumn,
124131
Integer targetRank,
125132
CteType cteType,
126-
boolean hasFilter) {
133+
boolean hasFilter,
134+
boolean hasValueName) {
127135
this.itemId = itemId;
128136
this.programStageUid = programStageUid;
129137
this.programIndicatorUid = programIndicatorUid;
@@ -137,6 +145,7 @@ private CteDefinition(
137145
this.targetRank = targetRank;
138146
this.cteType = cteType;
139147
this.hasFilter = hasFilter;
148+
this.hasValueName = hasValueName;
140149
}
141150

142151
/** Creates a CTE definition for program stage data elements. */
@@ -155,7 +164,8 @@ public CteDefinition(
155164
null, // joinColumn
156165
null, // targetRank
157166
CteType.PROGRAM_STAGE, // Set type
158-
false); // hasFilter
167+
false, // hasFilter
168+
false); // hasValueName
159169
this.offsets.add(offset);
160170
}
161171

@@ -177,6 +187,18 @@ public CteDefinition(
177187
int offset,
178188
boolean isRowContext,
179189
boolean hasFilter) {
190+
this(programStageUid, queryItemId, cteDefinition, offset, isRowContext, hasFilter, false);
191+
}
192+
193+
// Constructor for standard Program Stage CTEs with rowContext, hasFilter, and value_name
194+
public CteDefinition(
195+
String programStageUid,
196+
String queryItemId,
197+
String cteDefinition,
198+
int offset,
199+
boolean isRowContext,
200+
boolean hasFilter,
201+
boolean hasValueName) {
180202
this(
181203
queryItemId,
182204
programStageUid,
@@ -190,7 +212,8 @@ public CteDefinition(
190212
null, // joinColumn
191213
null, // targetRank
192214
CteType.PROGRAM_STAGE, // Set type
193-
hasFilter); // Pass hasFilter
215+
hasFilter, // Pass hasFilter
216+
hasValueName); // Pass hasValueName
194217
this.offsets.add(offset);
195218
}
196219

@@ -208,7 +231,8 @@ public CteDefinition(String cteDefinition, String aggregateWhereClause) {
208231
null, // joinColumn
209232
null, // targetRank
210233
CteType.BASE_AGGREGATION, // Set type
211-
false); // hasFilter
234+
false, // hasFilter
235+
false); // hasValueName
212236
}
213237

214238
/** Creates a CTE definition for program indicators. */
@@ -217,6 +241,17 @@ public static CteDefinition forProgramIndicator(
217241
AnalyticsType programIndicatorType,
218242
String cteDefinition,
219243
boolean requiresCoalesce) {
244+
return forProgramIndicator(
245+
programIndicatorUid, programIndicatorType, cteDefinition, requiresCoalesce, null);
246+
}
247+
248+
/** Creates a CTE definition for program indicators. */
249+
public static CteDefinition forProgramIndicator(
250+
String programIndicatorUid,
251+
AnalyticsType programIndicatorType,
252+
String cteDefinition,
253+
boolean requiresCoalesce,
254+
String joinColumn) {
220255
// Calls private constructor, passing CteType.PROGRAM_INDICATOR
221256
return new CteDefinition(
222257
programIndicatorUid, // itemId
@@ -228,12 +263,13 @@ public static CteDefinition forProgramIndicator(
228263
false, // isExists
229264
requiresCoalesce, // requiresCoalesce
230265
null, // aggregateWhereClause
231-
null, // joinColumn
266+
joinColumn, // joinColumn
232267
null, // targetRank
233268
programIndicatorType == AnalyticsType.EVENT
234269
? PROGRAM_INDICATOR_EVENT
235270
: PROGRAM_INDICATOR_ENROLLMENT, // Set type
236-
false); // hasFilter
271+
false, // hasFilter
272+
false); // hasValueName
237273
}
238274

239275
/** Creates a CTE definition for filter CTEs (replacing filter subqueries). */
@@ -253,7 +289,8 @@ public static CteDefinition forFilter(
253289
null, // joinColumn
254290
null, // targetRank
255291
CteType.FILTER, // Set type
256-
false); // hasFilter
292+
false, // hasFilter
293+
false); // hasValueName
257294
}
258295

259296
/**
@@ -280,7 +317,8 @@ public static CteDefinition forVariable(String key, String cteDefinitionSql, Str
280317
joinColumn, // Pass joinColumn
281318
null, // targetRank
282319
CteType.VARIABLE, // Set type
283-
false); // hasFilter
320+
false, // hasFilter
321+
false); // hasValueName
284322
}
285323

286324
/**
@@ -302,7 +340,8 @@ public static CteDefinition forProgramStageDataElement(
302340
joinColumn, // Pass joinColumn
303341
targetRank, // Pass targetRank
304342
CteType.PROGRAM_STAGE_DATE_ELEMENT, // Set type
305-
false); // hasFilter
343+
false, // hasFilter
344+
false); // hasValueName
306345
}
307346

308347
/**
@@ -328,7 +367,34 @@ public static CteDefinition forD2Function(
328367
joinColumn,
329368
null, // targetRank
330369
CteType.D2_FUNCTION,
331-
false); // hasFilter
370+
false, // hasFilter
371+
false); // hasValueName
372+
}
373+
374+
/**
375+
* Creates a CTE definition for a d2:relationshipCount aggregation, joined per trackedEntity.
376+
*
377+
* @param key A unique key identifying this relationship-count CTE instance.
378+
* @param cteDefinitionSql The SQL body for the CTE (selects {@code trackedentityid} and {@code
379+
* value}).
380+
* @return The created CteDefinition.
381+
*/
382+
public static CteDefinition forRelationshipCount(String key, String cteDefinitionSql) {
383+
return new CteDefinition(
384+
key,
385+
null,
386+
null,
387+
cteDefinitionSql,
388+
generateRandomAlias(),
389+
false,
390+
false,
391+
false,
392+
null,
393+
"trackedentity",
394+
null,
395+
CteType.D2_RELATIONSHIP_COUNT,
396+
false,
397+
false);
332398
}
333399

334400
public static CteDefinition forShadowTable(String tableName, String sql, CteType cteType) {
@@ -343,7 +409,8 @@ public static CteDefinition forShadowTable(String tableName, String sql, CteType
343409
null, // aggregateWhereClause
344410
null, // joinColumn
345411
null, // targetRank
346-
cteType, false); // hasFilter
412+
cteType, false, // hasFilter
413+
false); // hasValueName
347414
}
348415

349416
public CteDefinition setExists(boolean exists) {
@@ -374,6 +441,10 @@ public boolean isProgramStage() {
374441
return this.cteType == CteType.PROGRAM_STAGE && !isExists;
375442
}
376443

444+
public boolean hasValueName() {
445+
return hasValueName;
446+
}
447+
377448
private static String generateRandomAlias() {
378449
// Use the same alias generation as before
379450
return new RandomStringGenerator.Builder().withinRange('a', 'z').get().generate(5);

0 commit comments

Comments
 (0)