From 241e4807abba98b2316250e483d2b5dbbffddb70 Mon Sep 17 00:00:00 2001 From: Nikita Fomichev Date: Sun, 16 Aug 2026 00:10:33 +0200 Subject: [PATCH 1/4] fix(clickhouse): kill the two nightly oracle false positives (plan items 0a, 0b) Both families cost triage time on every NightlySQLancer run and both are violations of the fork's own soundness checklist. 0a. LimitRanking's LIMIT-BY cap was measured client-side: it read the key column through ComparatorHelper.getResultSetFirstColumnAsString, which routes every value through trimTrailingDotZeros. That helper rewrites '0.0' into '0', so a String key holding both values looked like one key appearing twice and the oracle reported a cap violation that did not exist (2026-08-04 and 2026-08-07 reproducers; replay against head shows uniqExact(c0) = count() = 10000 and LIMIT 1 BY returning exactly 10000 rows). The cap is now computed in ClickHouse: SELECT max(cnt) FROM (SELECT count() AS cnt FROM () GROUP BY lb_key) which is also cheaper, since 10000 rows no longer cross the wire. Verified on head 26.8.1.1470 against the exact false-positive shape: a String key holding '0.0', '0', '0.0' and 'x' reports max-per-key 1 under LIMIT 1 BY and 2 under LIMIT 2 BY, so the assertion still measures the real cap. Note that trimTrailingDotZeros is still applied by every other oracle. Scoping it to float columns, or replacing it with the ULP-tolerant comparison mode that already exists in ComparatorHelper, is a separate follow-up. 0b. The engine pool could pick ReplacingMergeTree() with a Bool sorting key and no ver argument. With a two-value key a background merge collapses visible cardinality between two reads, which produced the 2026-08-07 TLPWhere "size of the result sets mismatch (91 and 26)" report (91 = 7x13 before the merge, 26 = 2x13 after). Dedupe and collapse engines now require a non-degenerate key domain via hasDegenerateKeyDomain / isDedupeKeyColumn: Bool is rejected, and so is an Enum with fewer than MIN_DEDUPE_KEY_DOMAIN (8) entries, which today means every generated Enum because the type picker caps them at 5. pickEngine falls back to plain MergeTree when no eligible column exists, the dedupe fallback ORDER BY uses the same filter, and ReplacingMergeTree now always emits its ver argument instead of doing so half the time. Verified mid-run against system.tables on a 12-minute dev-VM run: 0 dedupe tables with a Bool or Enum sorting key, and 0 of 75 ReplacingMergeTree tables without a ver argument. --- .../gen/ClickHouseTableGenerator.java | 30 +++++++++++++++---- .../limit/ClickHouseLimitRankingOracle.java | 29 ++++++++++-------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java index 9e7083659..c5770127a 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java @@ -121,7 +121,9 @@ public void start() { boolean engineRequiresNonEmptyOrderBy = isDedupeEngine(engine); - String fallbackKeyColumn = columns.stream().filter(ClickHouseTableGenerator::isBareKeyColumn) + java.util.function.Predicate fallbackKeyFilter = engineRequiresNonEmptyOrderBy + ? ClickHouseTableGenerator::isDedupeKeyColumn : ClickHouseTableGenerator::isBareKeyColumn; + String fallbackKeyColumn = columns.stream().filter(fallbackKeyFilter) .map(ClickHouseSchema.ClickHouseColumn::getName).findFirst().orElse(columns.get(0).getName()); String fallbackOrderBy = engineRequiresNonEmptyOrderBy ? " ORDER BY " + fallbackKeyColumn + " " : " ORDER BY tuple() "; @@ -233,6 +235,21 @@ static boolean isBareKeyColumn(ClickHouseSchema.ClickHouseColumn col) { || u instanceof sqlancer.clickhouse.ClickHouseType.Time64; } + private static final int MIN_DEDUPE_KEY_DOMAIN = 8; + + static boolean hasDegenerateKeyDomain(ClickHouseSchema.ClickHouseColumn col) { + sqlancer.clickhouse.ClickHouseType u = col.getType().getTypeTerm().unwrap(); + if (u instanceof sqlancer.clickhouse.ClickHouseType.Primitive p + && p.kind() == sqlancer.clickhouse.ClickHouseType.Kind.Bool) { + return true; + } + return u instanceof sqlancer.clickhouse.ClickHouseType.Enum e && e.entries().size() < MIN_DEDUPE_KEY_DOMAIN; + } + + static boolean isDedupeKeyColumn(ClickHouseSchema.ClickHouseColumn col) { + return isBareKeyColumn(col) && !hasDegenerateKeyDomain(col); + } + static java.util.List pickDistinct(java.util.List src, int k) { java.util.List pool = new java.util.ArrayList<>(src); java.util.List out = new java.util.ArrayList<>(); @@ -247,6 +264,10 @@ private ClickHouseEngine pickEngine(List cols if (roll < 78) { return ClickHouseEngine.MergeTree; } + boolean hasDedupeKey = cols.stream().anyMatch(ClickHouseTableGenerator::isDedupeKeyColumn); + if (!hasDedupeKey) { + return ClickHouseEngine.MergeTree; + } if (roll < 86) { boolean hasVerCandidate = cols.stream().anyMatch(this::isValidReplacingVer); @@ -272,15 +293,14 @@ private ClickHouseEngine pickEngine(List cols } boolean hasSimpleAgg = cols.stream().anyMatch(ClickHouseTableGenerator::isSimpleAggregateColumn); - boolean hasBareKey = cols.stream().anyMatch(ClickHouseTableGenerator::isBareKeyColumn); - return hasSimpleAgg && hasBareKey ? ClickHouseEngine.AggregatingMergeTree : ClickHouseEngine.MergeTree; + return hasSimpleAgg ? ClickHouseEngine.AggregatingMergeTree : ClickHouseEngine.MergeTree; } private String renderEngineArgs(ClickHouseEngine engine) { if (engine == ClickHouseEngine.ReplacingMergeTree) { List candidates = columns.stream().filter(this::isValidReplacingVer) .collect(Collectors.toList()); - if (candidates.isEmpty() || !Randomly.getBoolean()) { + if (candidates.isEmpty()) { return ""; } return Randomly.fromList(candidates).getName(); @@ -545,7 +565,7 @@ private static boolean referencesUnorderableComposite(ClickHouseExpression expr) static boolean isValidOrderByForDedupe(ClickHouseExpression expr) { - return expr instanceof ClickHouseColumnReference cr && isBareKeyColumn(cr.getColumn()); + return expr instanceof ClickHouseColumnReference cr && isDedupeKeyColumn(cr.getColumn()); } static boolean isValidPartitionBy(ClickHouseExpression expr) { diff --git a/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java b/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java index a35902a6b..71715eed1 100644 --- a/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java +++ b/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java @@ -149,21 +149,26 @@ private static boolean isSubMultiset(List sub, List sup) { private void checkLimitByCap(String tableQ, String totalOrder, List columns) throws SQLException { String key = quote(Randomly.fromList(columns).getName()); long n = 1 + Randomly.getNotCachedInteger(0, 5); - String query = "SELECT toString(" + key + ") FROM " + tableQ + " ORDER BY " + totalOrder + " LIMIT " + n + String limitByQuery = "SELECT " + key + " AS lb_key FROM " + tableQ + " ORDER BY " + totalOrder + " LIMIT " + n + " BY " + key; + String query = "SELECT toString(max(cnt)) FROM (SELECT count() AS cnt FROM (" + limitByQuery + ") GROUP BY " + + "lb_key)"; - List keyValues = ComparatorHelper.getResultSetFirstColumnAsString(query, readErrors, state); - Map perKey = new LinkedHashMap<>(); - for (String v : keyValues) { - perKey.merge(v == null ? "\\N" : v, 1L, Long::sum); + List rows = ComparatorHelper.getResultSetFirstColumnAsString(query, readErrors, state); + if (rows.size() != 1 || rows.get(0) == null) { + throw new IgnoreMeException(); } - for (Map.Entry e : perKey.entrySet()) { - if (e.getValue() > n) { - throw new AssertionError(String.format( - "LimitRanking LIMIT-BY cap violation: key %s appears %d times but 'LIMIT %d BY %s' caps it at " - + "%d.%n Q: %s", - e.getKey(), e.getValue(), n, key, n, query)); - } + long maxPerKey; + try { + maxPerKey = Long.parseLong(rows.get(0).trim()); + } catch (NumberFormatException e) { + throw new IgnoreMeException(); + } + if (maxPerKey > n) { + throw new AssertionError(String.format( + "LimitRanking LIMIT-BY cap violation: the most frequent key appears %d times but 'LIMIT %d BY %s' " + + "caps it at %d.%n Q: %s", + maxPerKey, n, key, n, query)); } } From ad594e09e13fa04f560d1f03f8a06f44568e53db Mon Sep 17 00:00:00 2001 From: Nikita Fomichev Date: Sun, 16 Aug 2026 00:11:19 +0200 Subject: [PATCH 2/4] feat(clickhouse): P0 coverage items 1-6 of the 4-month gap audit Six coverage items from docs/plans/2026-08-15-001-feat-clickhouse-4month- coverage-gap-plan.md. Each new oracle is gated by a default-on ClickHouseOptions flag, has one ClickHouseOracleFactory entry, and one ALL_ORACLES token. Item 1, boolean-position and truth-value predicates (--truth-value-predicate-emission). generatePredicate() gains two arms: numeric columns wrapped in NOT (NOT x), NOT x, x IS [NOT] TRUE/FALSE/UNKNOWN, x IS NOT DISTINCT FROM lit, nullIf/ifNull/coalesce(x, lit), and String columns under LIKE/ILIKE ... ESCAPE. Half the time the wrapper is compared against a numeric or float constant, which puts a boolean-valued expression in *value* position -- the shape KeyCondition's inversion pushdown mishandles. Rendered through real AST nodes (ClickHouseUnaryPrefixOperation, ClickHousePostfixText, and the new ClickHouseWrappedExpression) rather than ClickHouseRawText, so the KeyCondition oracle's materialize() rewrite still reaches the column references. This immediately surfaces a real, unfiled wrong result on head 26.8.1.1470: (NOT (NOT c1)) <= 3.14 evaluates to 1 for every row in a projection, but as a WHERE clause it prunes parts, and EXPLAIN indexes=1 prints "Condition: (c1 in (-Inf, 3])". Root cause is the name == "not" branch of cloneDAGWithInversionPushDown in src/Storages/MergeTree/KeyCondition.cpp ignoring boolean_context, so two flips cancel and NOT NOT c1 degrades to bare c1. Wrong since at least 24.8. NoREC and TLPWhere catch it; KeyCondition does NOT, because neither materialize() nor any settings profile disables this pruning path. Item 2, ClickHouseFloatPruningOracle (--float-pruning-oracle). Private fixture with Float32/Float64/Nullable(Float64) columns holding NaN, +/-inf, -0.0 and NULL across several parts (one part all-NaN), with a float ORDER BY, an optional float PARTITION BY, minmax and bloom_filter skip indexes and materialized statistics. Two assertions: a negated float comparison in WHERE must select the same key multiset as the same predicate evaluated as a groupArrayIf aggregate argument over a full scan, and count(P) + count(NOT P) + count(P IS NULL) must equal count(*). The full-scan reference is load bearing. The plan specified materialize() plus a pruning-off settings profile, and that is not enough: verified on head, none of materialize(), use_skip_indexes=0, use_skip_indexes_on_data_read=0, allow_statistics_optimize=0, convert_query_to_cnf=0, optimize_move_to_prewhere=0, force_primary_key=0 or query_plan_enable_optimizations=0 defeats partition-level or primary-key-level pruning, so that arm would have compared two equally-wrong answers. A predicate that never reaches a WHERE clause cannot be pruned; copy that pattern for any future pruning oracle. No float aggregate is computed anywhere, only count() and a key-column row set, so the exact-integer-aggregate rule is not violated. Item 3, ClickHouseDistributedPlanEquivalenceOracle (--distributed-plan-equivalence-oracle). One generated read must return the same multiset under plain local execution, make_distributed_plan = 1, serialize_query_plan = 1, a cluster('default', ...) read with parallel_replicas_local_plan on and off, and enable_parallel_replicas = 1 with max_parallel_replicas = 3 over both the local relation and a Distributed(...) wrapper. Five query shapes, one of which is a three-way comma join whose middle relation is a VIEW (the ClickHouse#111727 shape). Self-contained multi-block fixture. The single-node default cluster exists on head, so all six profiles genuinely execute rather than silently erroring out. Item 4, views and comma joins in multi-relation FROM lists (--persistent-view-emission, --comma-join-emission). Two independent gaps: - Views were already visible to the join picker, but ViewEquivalence creates and drops its view inside a single iteration, so no schema snapshot ever held one. A VIEW provider action now creates up to 3 plain v views per database (the schema reader marks a relation as a view by its name prefix). - Every CROSS join was silently an INNER join, because the generator always handed it an ON clause. FROM t0, v0, t1 was therefore unreachable. The join generator now emits genuine ON-less CROSS joins and chains of up to four relations, and the visitor renders an ON-less CROSS as a comma. A bare "JOIN x" with no ON is a SYNTAX_ERROR in ClickHouse, hence the comma. Comma joins are rate-limited to 10% of CROSS picks on purpose. At 50% a 40-minute dev-VM run spent roughly 40% of its thread budget on three- and four-way cartesian products timing out at max_execution_time, and throughput fell from about 100 to about 10 queries/s. At 10% throughput holds at 75-95 queries/s and the shape still appears about 90 times per 30 minutes. Because views are now visible to every oracle, write paths must filter them: ClickHouseAlterGenerator and ClickHouseMutationGenerator move to getDatabaseTablesWithoutViews(), ClickHouseCERTOracle, ClickHouseRowPolicyOracle and ClickHouseQueryConditionCacheOracle grow !isView() filters, TLPBase gates PREWHERE on !isView(), and ILLEGAL_PREWHERE plus "is not supported by storage View" are tolerated globally as a backstop. Any new oracle that INSERTs, ALTERs or OPTIMIZEs a schema-picked table must do the same. ClickHouse#114113 ("Left and right columns have same names" out of chooseJoinOrder, a server abort on sanitizer builds) is pinned via ClickHouseErrors.getKnownOpenJoinOrderBugs() so runs do not drown in it. It did not reproduce on the release build 26.8.1.1470 with the plan's minimal repro. Remove the pin when the issue closes. Item 5, join-order enumerator sweep. ClickHouseJoinReorderOracle runs the same N-way join under query_plan_optimize_join_order_algorithm in {greedy, dpsize, dpsub, dphyp, dphyp+greedy, dpsub+greedy}, plus query_plan_enable_optimizations = 0, query_plan_join_shard_by_pk_ranges = 1 and query_plan_optimize_join_order_max_searched_plans = 1, and asserts identical multisets against the default-arm result. The oracle also builds a VIEW over one of its private tables 40% of the time, which is the deterministic delivery vehicle for the view-in-multi-join shape. The setting is query_plan_optimize_join_order_algorithm, not query_plan_join_reorder_algorithm as the plan guessed. dpsize and dphyp support inner joins only and raise Code 717 EXPERIMENTAL_FEATURE_ERROR "Failed to find a valid join order, try adding 'greedy' algorithm as fallback" on outer, semi and anti chains; that is a legitimate unsupported-shape error and is tolerated in a dedicated algorithmErrors set. Without that tolerance the first validation run produced 924 junk reproducers in 12 minutes. Item 6, ClickHouseCodecRoundtripOracle (--codec-roundtrip-oracle). A table with random per-type CODEC(...) declarations and a CODEC(NONE) mirror holding the same inserted rows (including NaN, +/-inf, -0.0 and denormals) must answer the same read identically, still after OPTIMIZE ... FINAL, and still after an ALTER TABLE ... MODIFY COLUMN ... CODEC mutation, which is where mixed-codec parts and adaptive selection come in. The coded table sometimes carries allow_experimental_adaptive_codec_selection = 1. Lossy codecs (SZ3, ZXC) are partitioned off the equality arm by an explicit allowlist and only have their row count and NULL mask asserted; if the lossy DDL is rejected the oracle retries with a lossless float codec rather than dropping the iteration. ALP was also added to the general schema's float codec pool. FloatPruning is deliberately absent from ALL_ORACLES. It is a positive-control detector for ClickHouse#113417 and #112036, which reproduce on head at DEFAULT settings, so it asserts on nearly every iteration: a 6-minute standalone run produced 326 worker deaths over 175 queries. A constantly firing oracle orphans a database per iteration and wedges the server under a squeezed memory cap, which is what stalled the 2026-06-14 20h run via TextIndexDirectRead. Run it with --oracles FloatPruning, and add it back here once those issues close. --- .claude/run-sqlancer.sh | 9 +- src/sqlancer/clickhouse/ClickHouseErrors.java | 10 +- .../clickhouse/ClickHouseOptions.java | 18 + .../clickhouse/ClickHouseOracleFactory.java | 24 ++ .../clickhouse/ClickHouseProvider.java | 8 +- .../clickhouse/ClickHouseToStringVisitor.java | 12 + .../clickhouse/ClickHouseVisitor.java | 4 + .../ast/ClickHouseWrappedExpression.java | 26 ++ .../gen/ClickHouseAlterGenerator.java | 2 +- .../gen/ClickHouseColumnBuilder.java | 1 + .../gen/ClickHouseExpressionGenerator.java | 211 ++++++++-- .../gen/ClickHouseMutationGenerator.java | 2 +- .../gen/ClickHouseViewGenerator.java | 69 ++++ .../oracle/cert/ClickHouseCERTOracle.java | 3 +- .../codec/ClickHouseCodecRoundtripOracle.java | 367 ++++++++++++++++++ ...HouseDistributedPlanEquivalenceOracle.java | 258 ++++++++++++ .../join/ClickHouseJoinReorderOracle.java | 71 +++- .../keycond/ClickHouseFloatPruningOracle.java | 312 +++++++++++++++ .../ClickHouseQueryConditionCacheOracle.java | 4 + .../rowpolicy/ClickHouseRowPolicyOracle.java | 3 +- .../oracle/tlp/ClickHouseTLPBase.java | 3 +- 21 files changed, 1367 insertions(+), 50 deletions(-) create mode 100644 src/sqlancer/clickhouse/ast/ClickHouseWrappedExpression.java create mode 100644 src/sqlancer/clickhouse/gen/ClickHouseViewGenerator.java create mode 100644 src/sqlancer/clickhouse/oracle/codec/ClickHouseCodecRoundtripOracle.java create mode 100644 src/sqlancer/clickhouse/oracle/distributed/ClickHouseDistributedPlanEquivalenceOracle.java create mode 100644 src/sqlancer/clickhouse/oracle/keycond/ClickHouseFloatPruningOracle.java diff --git a/.claude/run-sqlancer.sh b/.claude/run-sqlancer.sh index d3fcdc370..5a8415586 100755 --- a/.claude/run-sqlancer.sh +++ b/.claude/run-sqlancer.sh @@ -32,10 +32,17 @@ EXTRA_CH_ARGS="" # factory but had drifted out of this list (never ran under --oracles all); re-added 2026-06-10. # ExtendedDatetime/JoinUseNulls/QueryCache appended 2026-06-11 (settings-coverage plan section 3/5 # targeted oracles; tmp/ch-settings-to-test-in-sqlancer.md). +# FloatPruning is DELIBERATELY absent from ALL_ORACLES (2026-08-15): it is a positive-control +# detector for ClickHouse #113417/#112036 (NaN rows dropped by float part pruning under a negated +# comparison), which still reproduce on head at DEFAULT settings, so it asserts on essentially every +# iteration -- a 6-minute standalone run produced 326 worker deaths over 175 queries. A constantly +# firing oracle orphans a database per iteration and wedges CH under a squeezed -m cap (that is what +# stalled the 2026-06-14 20h run via TextIndexDirectRead). Run it standalone with +# `--oracles FloatPruning`, and add it back here once those two issues close. # 26.x coverage oracles (TextIndexLike..StatsToggle) appended 2026-06-10 after their convergence # run: 3h x 41 oracles x 1.09M queries with --eet-26x-modes/--variant-where-emission on produced # 0 false positives and 1 genuine CH wrong-result (JoinReorder, ANTI/SEMI/INNER chain). -ALL_ORACLES="TLPWhere,TLPDistinct,TLPGroupBy,TLPAggregate,TLPHaving,NoREC,PQS,CERT,CODDTest,SEMR,SEMRMulti,EET,SetOpTLP,CombinatorTLP,QccCache,SortedUnionLimitBy,SchemaRoundtrip,JoinAlgorithm,Cast,Parallelism,PartitionMirror,KeyCondition,TableFunctionIN,ViewEquivalence,AggregateStateRoundtrip,MaterializedViewConsistency,FinalMerge,ProjectionToggle,PatchPartConsistency,DictGetVsJoin,WindowEquivalence,DynamicSubcolumn,SubqueryMaterialize,MutationAnalyzer,TextIndexLike,TopK,JoinReorder,NaturalJoin,JsonSkipIndex,MaterializedCte,StatsToggle,ExtendedDatetime,JoinUseNulls,QueryCache,TextIndexDirectRead,TextIndexContainer,TextIndexLifecycle,PrewhereEquivalence,ReadInOrderToggle,CountOptimization,LazyMaterializationToggle,ReplacingDedup,QuantileConsistency,UniqExactness,ArgExtremum,MaterializedColumn,GroupingDecomposition,LimitRanking,WindowFrame,SemiJoinRewrite,ColumnTransformer,EngineEquivalence,CoalescingFinal,JoinGetSet,RemoteLocalEquivalence,MapTupleContainer,GeoMetamorphic,VariantSubcolumn,AggregateStateExpansion,SequenceFunnel,PartitionLifecycle,AlterModifyConsistency,TtlDeterminism,InsertDedup,TokenBf,VectorIndexRecall,SampleClause,DistributedTable,AsofJoin,CubeGroupingSets,PasteJoin,CorrelatedSubquery,BitFunction,ArrayFunction,StringFunction,AggregateFunctionColumn,TimezoneDatetime,ArrayJoinUnfold,WindowFrameGroundTruth,JoinUsing,WithFill,SettingFlip,ConcurrentMutation,LowCardinalityEquivalence" +ALL_ORACLES="TLPWhere,TLPDistinct,TLPGroupBy,TLPAggregate,TLPHaving,NoREC,PQS,CERT,CODDTest,SEMR,SEMRMulti,EET,SetOpTLP,CombinatorTLP,QccCache,SortedUnionLimitBy,SchemaRoundtrip,JoinAlgorithm,Cast,Parallelism,PartitionMirror,KeyCondition,TableFunctionIN,ViewEquivalence,AggregateStateRoundtrip,MaterializedViewConsistency,FinalMerge,ProjectionToggle,PatchPartConsistency,DictGetVsJoin,WindowEquivalence,DynamicSubcolumn,SubqueryMaterialize,MutationAnalyzer,TextIndexLike,TopK,JoinReorder,NaturalJoin,JsonSkipIndex,MaterializedCte,StatsToggle,ExtendedDatetime,JoinUseNulls,QueryCache,TextIndexDirectRead,TextIndexContainer,TextIndexLifecycle,PrewhereEquivalence,ReadInOrderToggle,CountOptimization,LazyMaterializationToggle,ReplacingDedup,QuantileConsistency,UniqExactness,ArgExtremum,MaterializedColumn,GroupingDecomposition,LimitRanking,WindowFrame,SemiJoinRewrite,ColumnTransformer,EngineEquivalence,CoalescingFinal,JoinGetSet,RemoteLocalEquivalence,MapTupleContainer,GeoMetamorphic,VariantSubcolumn,AggregateStateExpansion,SequenceFunnel,PartitionLifecycle,AlterModifyConsistency,TtlDeterminism,InsertDedup,TokenBf,VectorIndexRecall,SampleClause,DistributedTable,AsofJoin,CubeGroupingSets,PasteJoin,CorrelatedSubquery,BitFunction,ArrayFunction,StringFunction,AggregateFunctionColumn,TimezoneDatetime,ArrayJoinUnfold,WindowFrameGroundTruth,JoinUsing,WithFill,SettingFlip,ConcurrentMutation,LowCardinalityEquivalence,DistributedPlanEquivalence,CodecRoundtrip" usage() { cat < getExpectedExpressionErrors() { "CAST AS FixedString is only implemented", "default expression and column type are incompatible", - "Incompatible data types between aggregate function", "NOT_IMPLEMENTED"); + "Incompatible data types between aggregate function", "NOT_IMPLEMENTED", + + "does not support PREWHERE", "ILLEGAL_PREWHERE", "is not supported by storage View"); } public static void addExpectedExpressionErrors(ExpectedErrors errors) { errors.addAll(getExpectedExpressionErrors()); + errors.addAll(getKnownOpenJoinOrderBugs()); + errors.addAll(getStatisticsErrors()); errors.addAll(getEnumErrors()); @@ -247,6 +251,10 @@ public static List getKnownOpenMutationAnalyzerBugs() { return List.of("is already registered"); } + public static List getKnownOpenJoinOrderBugs() { + return List.of("Left and right columns have same names"); + } + public static boolean isToleratedException(Throwable e) { ExpectedErrors errors = ExpectedErrors.newErrors().with(getExpectedExpressionErrors()) .with(getSessionSettingsErrors()).build(); diff --git a/src/sqlancer/clickhouse/ClickHouseOptions.java b/src/sqlancer/clickhouse/ClickHouseOptions.java index 0e6b77aac..d14f3d28e 100644 --- a/src/sqlancer/clickhouse/ClickHouseOptions.java +++ b/src/sqlancer/clickhouse/ClickHouseOptions.java @@ -62,6 +62,15 @@ public class ClickHouseOptions implements DBMSSpecificOptions create(ClickHouseGlobalState globalStat public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { return new ClickHouseLowCardinalityEquivalenceOracle(globalState); } + }, + DistributedPlanEquivalence { + + @Override + public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { + return new ClickHouseDistributedPlanEquivalenceOracle(globalState); + } + }, + FloatPruning { + + @Override + public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { + return new ClickHouseFloatPruningOracle(globalState); + } + }, + CodecRoundtrip { + + @Override + public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { + return new ClickHouseCodecRoundtripOracle(globalState); + } } } diff --git a/src/sqlancer/clickhouse/ClickHouseProvider.java b/src/sqlancer/clickhouse/ClickHouseProvider.java index d837a5e5c..9404583d4 100644 --- a/src/sqlancer/clickhouse/ClickHouseProvider.java +++ b/src/sqlancer/clickhouse/ClickHouseProvider.java @@ -24,6 +24,7 @@ import sqlancer.clickhouse.gen.ClickHouseInsertGenerator; import sqlancer.clickhouse.gen.ClickHouseMutationGenerator; import sqlancer.clickhouse.gen.ClickHouseTableGenerator; +import sqlancer.clickhouse.gen.ClickHouseViewGenerator; import sqlancer.common.query.SQLQueryAdapter; import sqlancer.common.query.SQLQueryProvider; @@ -40,7 +41,9 @@ public enum Action implements AbstractAction { ALTER(ClickHouseAlterGenerator::getQuery), - MUTATION(ClickHouseMutationGenerator::getQuery); + MUTATION(ClickHouseMutationGenerator::getQuery), + + VIEW(ClickHouseViewGenerator::getQuery); private final SQLQueryProvider sqlQueryProvider; @@ -65,6 +68,9 @@ private static int mapActions(ClickHouseGlobalState globalState, Action a) { case MUTATION: return Randomly.fromOptions(0, 0, 0, 0, 1, 1, 1, 2); + case VIEW: + + return Randomly.fromOptions(0, 0, 0, 1); default: throw new AssertionError(a); } diff --git a/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java b/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java index 68109da5f..881159812 100644 --- a/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java +++ b/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java @@ -217,6 +217,11 @@ public void visit(ClickHouseCastOperation cast) { @Override public void visit(ClickHouseExpression.ClickHouseJoin join) { ClickHouseExpression.ClickHouseJoin.JoinType type = join.getType(); + if (type == ClickHouseExpression.ClickHouseJoin.JoinType.CROSS && join.getOnClause() == null) { + sb.append(", "); + visit(join.getRightTable()); + return; + } if (type == ClickHouseExpression.ClickHouseJoin.JoinType.CROSS) { sb.append(" JOIN "); visit(join.getRightTable()); @@ -302,6 +307,13 @@ public void visit(sqlancer.clickhouse.ast.ClickHouseRawText raw) { sb.append(raw.getSql()); } + @Override + public void visit(sqlancer.clickhouse.ast.ClickHouseWrappedExpression wrapped) { + sb.append(wrapped.getPrefix()); + visit(wrapped.getExpression()); + sb.append(wrapped.getSuffix()); + } + @Override public void visit(ClickHouseExpression.ClickHousePostfixText op) { diff --git a/src/sqlancer/clickhouse/ClickHouseVisitor.java b/src/sqlancer/clickhouse/ClickHouseVisitor.java index be693e54a..de8da1152 100644 --- a/src/sqlancer/clickhouse/ClickHouseVisitor.java +++ b/src/sqlancer/clickhouse/ClickHouseVisitor.java @@ -81,6 +81,8 @@ default void visit(ClickHouseExpression.ClickHousePostfixText op) { void visit(sqlancer.clickhouse.ast.ClickHouseRawText raw); + void visit(sqlancer.clickhouse.ast.ClickHouseWrappedExpression wrapped); + default void visit(ClickHouseExpression expr) { if (expr instanceof ClickHouseBinaryFunctionOperation) { visit((ClickHouseBinaryFunctionOperation) expr); @@ -126,6 +128,8 @@ default void visit(ClickHouseExpression expr) { visit((sqlancer.clickhouse.ast.ClickHouseDynamicElement) expr); } else if (expr instanceof sqlancer.clickhouse.ast.ClickHouseRawText) { visit((sqlancer.clickhouse.ast.ClickHouseRawText) expr); + } else if (expr instanceof sqlancer.clickhouse.ast.ClickHouseWrappedExpression) { + visit((sqlancer.clickhouse.ast.ClickHouseWrappedExpression) expr); } else if (expr instanceof ClickHouseExpression.ClickHouseJoinOnClause) { visit((ClickHouseExpression.ClickHouseJoinOnClause) expr); } else { diff --git a/src/sqlancer/clickhouse/ast/ClickHouseWrappedExpression.java b/src/sqlancer/clickhouse/ast/ClickHouseWrappedExpression.java new file mode 100644 index 000000000..569493525 --- /dev/null +++ b/src/sqlancer/clickhouse/ast/ClickHouseWrappedExpression.java @@ -0,0 +1,26 @@ +package sqlancer.clickhouse.ast; + +public class ClickHouseWrappedExpression extends ClickHouseExpression { + + private final String prefix; + private final ClickHouseExpression expression; + private final String suffix; + + public ClickHouseWrappedExpression(String prefix, ClickHouseExpression expression, String suffix) { + this.prefix = prefix; + this.expression = expression; + this.suffix = suffix; + } + + public String getPrefix() { + return prefix; + } + + public ClickHouseExpression getExpression() { + return expression; + } + + public String getSuffix() { + return suffix; + } +} diff --git a/src/sqlancer/clickhouse/gen/ClickHouseAlterGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseAlterGenerator.java index ef279942c..903ae631c 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseAlterGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseAlterGenerator.java @@ -25,7 +25,7 @@ private enum AlterKind { } public static SQLQueryAdapter getQuery(ClickHouseGlobalState state) { - List tables = state.getSchema().getDatabaseTables(); + List tables = state.getSchema().getDatabaseTablesWithoutViews(); if (tables.isEmpty()) { throw new IgnoreMeException(); } diff --git a/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java b/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java index 7a64b02ad..3be62a2d3 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java @@ -198,6 +198,7 @@ private static String pickCodec(ClickHouseSchema.ClickHouseLancerDataType dataTy if (isFloat) { options.add("Gorilla, LZ4"); options.add("FPC, LZ4"); + options.add("ALP, LZ4"); } if ((isNumericIntegral || isDateLike) && Randomly.getBooleanWithSmallProbability()) { diff --git a/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java index bbe8cd54b..caabc87b7 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java @@ -455,6 +455,123 @@ public ClickHouseExpression generateTextSearchPredicate(List BOOLEAN_POSITION_CONSTANTS = List.of("3.14", "0.5", "-0.5", "1.5", "0", "1", "2", + "-1"); + + private static boolean isNullableTerm(ClickHouseType term) { + if (term instanceof Nullable) { + return true; + } + if (term instanceof LowCardinality lc) { + return isNullableTerm(lc.inner()); + } + return false; + } + + public ClickHouseExpression generateTruthValuePredicate(List columns) { + List numeric = numericColumns(columns); + if (numeric.isEmpty()) { + return null; + } + TruthValueWrapper wrapper = Randomly.fromOptions(TruthValueWrapper.values()); + List pool = numeric; + if (wrapper == TruthValueWrapper.IS_UNKNOWN || wrapper == TruthValueWrapper.IS_NOT_UNKNOWN) { + pool = numeric.stream().filter(c -> isNullableTerm(c.getColumn().getType().getTypeTerm())) + .collect(Collectors.toList()); + if (pool.isEmpty()) { + return null; + } + } + ClickHouseColumnReference col = Randomly.fromList(pool); + boolean booleanValued = wrapper != TruthValueWrapper.NULL_IF && wrapper != TruthValueWrapper.IF_NULL + && wrapper != TruthValueWrapper.COALESCE; + ClickHouseExpression wrapped = renderTruthValueWrapper(wrapper, col); + if (wrapped == null) { + return null; + } + if (!booleanValued && !Randomly.getBoolean()) { + return new sqlancer.clickhouse.ast.ClickHouseWrappedExpression("(", wrapped, ") != 0"); + } + if (Randomly.getBoolean()) { + return wrapped; + } + ClickHouseExpression literal = new sqlancer.clickhouse.ast.ClickHouseRawText( + Randomly.fromList(BOOLEAN_POSITION_CONSTANTS)); + return new ClickHouseBinaryComparisonOperation(wrapped, literal, + Randomly.fromOptions(ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.SMALLER, + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.SMALLER_EQUALS, + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.GREATER, + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.GREATER_EQUALS, + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.EQUALS, + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.NOT_EQUALS)); + } + + private ClickHouseExpression renderTruthValueWrapper(TruthValueWrapper wrapper, ClickHouseColumnReference col) { + switch (wrapper) { + case NOT_NOT: + return new ClickHouseUnaryPrefixOperation( + new ClickHouseUnaryPrefixOperation(col, ClickHouseUnaryPrefixOperator.NOT), + ClickHouseUnaryPrefixOperator.NOT); + case NOT: + return new ClickHouseUnaryPrefixOperation(col, ClickHouseUnaryPrefixOperator.NOT); + case IS_TRUE: + return new ClickHouseExpression.ClickHousePostfixText(col, "IS TRUE", null); + case IS_NOT_TRUE: + return new ClickHouseExpression.ClickHousePostfixText(col, "IS NOT TRUE", null); + case IS_FALSE: + return new ClickHouseExpression.ClickHousePostfixText(col, "IS FALSE", null); + case IS_NOT_FALSE: + return new ClickHouseExpression.ClickHousePostfixText(col, "IS NOT FALSE", null); + case IS_UNKNOWN: + return new ClickHouseExpression.ClickHousePostfixText(col, "IS UNKNOWN", null); + case IS_NOT_UNKNOWN: + return new ClickHouseExpression.ClickHousePostfixText(col, "IS NOT UNKNOWN", null); + case IS_NOT_DISTINCT_FROM: + return new ClickHouseExpression.ClickHousePostfixText(col, + "IS NOT DISTINCT FROM " + renderColumnLiteral(col), null); + case NULL_IF: + return new sqlancer.clickhouse.ast.ClickHouseWrappedExpression("nullIf(", col, + ", " + renderColumnLiteral(col) + ")"); + case IF_NULL: + return new sqlancer.clickhouse.ast.ClickHouseWrappedExpression("ifNull(", col, + ", " + renderColumnLiteral(col) + ")"); + case COALESCE: + return new sqlancer.clickhouse.ast.ClickHouseWrappedExpression("coalesce(", col, + ", " + renderColumnLiteral(col) + ")"); + default: + throw new AssertionError(wrapper); + } + } + + private String renderColumnLiteral(ClickHouseColumnReference col) { + ClickHouseType term = col.getColumn().getType().getTypeTerm(); + ClickHouseExpression literal = generateConstantFromTerm(term instanceof Nullable n ? n.inner() : term); + return ClickHouseToStringVisitor.asString(literal); + } + + private static final List LIKE_ESCAPE_PATTERNS = List.of("a#%b", "#_x", "%#%%", "#%", "x#_%"); + + public ClickHouseExpression generateLikeEscapePredicate(List columns) { + List stringCols = new java.util.ArrayList<>(); + for (ClickHouseColumnReference c : columns) { + if (c.getColumn().getType().getType() == ClickHouseDataType.String) { + stringCols.add(c); + } + } + if (stringCols.isEmpty()) { + return null; + } + String op = Randomly.fromOptions("LIKE ", "NOT LIKE ", "ILIKE ", "NOT ILIKE "); + String pattern = Randomly.fromList(LIKE_ESCAPE_PATTERNS); + return new ClickHouseExpression.ClickHousePostfixText(Randomly.fromList(stringCols), + op + "'" + pattern + "' ESCAPE '#'", null); + } + public ClickHouseExpression generateDateTransform(List columns) { List dateCols = new java.util.ArrayList<>(); boolean dateTimeResolution = false; @@ -926,27 +1043,44 @@ public List getRandomJoinClauses(ClickHouse } List leftTables = new ArrayList<>(); leftTables.add(left); - if (Randomly.getBoolean() && !tables.isEmpty()) { - int nrJoinClauses = (int) Randomly.getNotCachedInteger(0, tables.size()); - for (int i = 0; i < nrJoinClauses; i++) { - ClickHouseTableReference leftTable = leftTables - .get((int) Randomly.getNotCachedInteger(0, leftTables.size())); - ClickHouseTableReference rightTable = new ClickHouseTableReference(Randomly.fromList(tables), - "right_" + i); - ClickHouseExpression.ClickHouseJoinOnClause joinClause = generateJoinClause(leftTable, rightTable); - - ClickHouseExpression.ClickHouseJoin.JoinType options = Randomly.fromList(DETERMINISTIC_JOIN_TYPES); - ClickHouseExpression.ClickHouseJoin j = new ClickHouseExpression.ClickHouseJoin(leftTable, rightTable, - options, joinClause); - - if (joinClause != null) { - j.setOnClause(maybeEnrichJoinOnClause(joinClause, options)); - } - joinStatements.add(j); + appendJoinChain(joinStatements, leftTables, tables); + return joinStatements; + } + + private void appendJoinChain(List joinStatements, + List leftTables, List relations) { + if (relations.isEmpty() || !Randomly.getBoolean()) { + return; + } + boolean commaJoins = globalState.getClickHouseOptions().commaJoinEmission; + int bound = commaJoins ? Math.min(4, relations.size() + 2) : relations.size(); + int nrJoinClauses = (int) Randomly.getNotCachedInteger(0, bound); + if (commaJoins && nrJoinClauses == 0) { + nrJoinClauses = 1; + } + for (int i = 0; i < nrJoinClauses; i++) { + ClickHouseTableReference leftTable = leftTables + .get((int) Randomly.getNotCachedInteger(0, leftTables.size())); + ClickHouseTableReference rightTable = new ClickHouseTableReference(Randomly.fromList(relations), + "right_" + i); + ClickHouseExpression.ClickHouseJoin.JoinType options = Randomly.fromList(DETERMINISTIC_JOIN_TYPES); + + if (commaJoins && options == ClickHouseExpression.ClickHouseJoin.JoinType.CROSS + && Randomly.getBooleanWithRatherLowProbability()) { + joinStatements.add(new ClickHouseExpression.ClickHouseJoin(leftTable, rightTable, options)); leftTables.add(rightTable); + continue; } + + ClickHouseExpression.ClickHouseJoinOnClause joinClause = generateJoinClause(leftTable, rightTable); + ClickHouseExpression.ClickHouseJoin j = new ClickHouseExpression.ClickHouseJoin(leftTable, rightTable, + options, joinClause); + if (joinClause != null) { + j.setOnClause(maybeEnrichJoinOnClause(joinClause, options)); + } + joinStatements.add(j); + leftTables.add(rightTable); } - return joinStatements; } private static final List DETERMINISTIC_JOIN_TYPES = List.of( @@ -1349,6 +1483,26 @@ public ClickHouseExpression generatePredicate() { } } + if (globalState.getClickHouseOptions().truthValuePredicateEmission + && Randomly.getBooleanWithRatherLowProbability()) { + ClickHouseExpression truthPred = generateTruthValuePredicate(columnRefs); + if (truthPred != null) { + return Randomly.getBoolean() ? truthPred + : new ClickHouseBinaryLogicalOperation(base, truthPred, + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND); + } + } + + if (globalState.getClickHouseOptions().truthValuePredicateEmission + && Randomly.getBooleanWithSmallProbability()) { + ClickHouseExpression likePred = generateLikeEscapePredicate(columnRefs); + if (likePred != null) { + return Randomly.getBoolean() ? likePred + : new ClickHouseBinaryLogicalOperation(base, likePred, + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND); + } + } + if (globalState.getClickHouseOptions().textSearchPredicateEmission && Randomly.getBooleanWithSmallProbability()) { ClickHouseExpression textPred = generateTextSearchPredicate(columnRefs); @@ -1532,26 +1686,7 @@ public List getRandomJoinClauses() { } List leftTables = new ArrayList<>(); leftTables.add(new ClickHouseTableReference(tables.get(0), null)); - if (Randomly.getBoolean() && !tables.isEmpty()) { - int nrJoinClauses = (int) Randomly.getNotCachedInteger(0, tables.size()); - for (int i = 0; i < nrJoinClauses; i++) { - ClickHouseTableReference leftTable = leftTables - .get((int) Randomly.getNotCachedInteger(0, leftTables.size())); - ClickHouseTableReference rightTable = new ClickHouseTableReference(Randomly.fromList(tables), - "right_" + i); - ClickHouseExpression.ClickHouseJoinOnClause joinClause = generateJoinClause(leftTable, rightTable); - - ClickHouseExpression.ClickHouseJoin.JoinType options = Randomly.fromList(DETERMINISTIC_JOIN_TYPES); - ClickHouseExpression.ClickHouseJoin j = new ClickHouseExpression.ClickHouseJoin(leftTable, rightTable, - options, joinClause); - - if (joinClause != null) { - j.setOnClause(maybeEnrichJoinOnClause(joinClause, options)); - } - joinStatements.add(j); - leftTables.add(rightTable); - } - } + appendJoinChain(joinStatements, leftTables, tables); return joinStatements; } diff --git a/src/sqlancer/clickhouse/gen/ClickHouseMutationGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseMutationGenerator.java index e90219fb1..f78bd7d47 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseMutationGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseMutationGenerator.java @@ -73,7 +73,7 @@ private static ClickHouseExpression generateWhere(ClickHouseExpressionGenerator } public static SQLQueryAdapter getQuery(ClickHouseGlobalState state) { - List tables = state.getSchema().getDatabaseTables(); + List tables = state.getSchema().getDatabaseTablesWithoutViews(); if (tables.isEmpty()) { throw new IgnoreMeException(); } diff --git a/src/sqlancer/clickhouse/gen/ClickHouseViewGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseViewGenerator.java new file mode 100644 index 000000000..37dbd55e1 --- /dev/null +++ b/src/sqlancer/clickhouse/gen/ClickHouseViewGenerator.java @@ -0,0 +1,69 @@ +package sqlancer.clickhouse.gen; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.clickhouse.ClickHouseSchema.ClickHouseColumn; +import sqlancer.clickhouse.ClickHouseSchema.ClickHouseTable; +import sqlancer.clickhouse.ClickHouseToStringVisitor; +import sqlancer.clickhouse.ast.ClickHouseExpression; +import sqlancer.common.query.ExpectedErrors; +import sqlancer.common.query.SQLQueryAdapter; + +public final class ClickHouseViewGenerator { + + private static final AtomicLong VIEW_COUNTER = new AtomicLong(); + private static final int MAX_VIEWS_PER_DATABASE = 3; + + private ClickHouseViewGenerator() { + } + + public static SQLQueryAdapter getQuery(ClickHouseGlobalState state) { + if (!state.getClickHouseOptions().persistentViewEmission) { + throw new IgnoreMeException(); + } + if (state.getSchema().getViews().size() >= MAX_VIEWS_PER_DATABASE) { + throw new IgnoreMeException(); + } + List tables = state.getSchema().getDatabaseTablesWithoutViews(); + if (tables.isEmpty()) { + throw new IgnoreMeException(); + } + ClickHouseTable table = Randomly.fromList(tables); + List readable = table.getColumns(); + if (readable.isEmpty()) { + throw new IgnoreMeException(); + } + + String db = state.getDatabaseName(); + String name = "v" + VIEW_COUNTER.incrementAndGet(); + StringBuilder sb = new StringBuilder("CREATE VIEW IF NOT EXISTS ").append(db).append('.').append(name) + .append(" AS SELECT "); + if (Randomly.getBoolean()) { + sb.append("*"); + } else { + sb.append(readable.stream().map(ClickHouseColumn::getName).collect(Collectors.joining(", "))); + } + sb.append(" FROM ").append(db).append('.').append(table.getName()); + if (Randomly.getBooleanWithRatherLowProbability()) { + ClickHouseExpressionGenerator gen = new ClickHouseExpressionGenerator(state).allowAggregates(false); + gen.addColumns(readable.stream().map(c -> c.asColumnReference("")).collect(Collectors.toList())); + ClickHouseExpression predicate = gen.generatePredicate(); + sb.append(" WHERE ").append(ClickHouseToStringVisitor.asString(predicate)); + } + + ExpectedErrors errors = new ExpectedErrors(); + ClickHouseErrors.addExpectedExpressionErrors(errors); + errors.add("UNKNOWN_TABLE"); + errors.add("TABLE_ALREADY_EXISTS"); + errors.add("ACCESS_DENIED"); + errors.add("Not enough privileges"); + errors.add("UNSUPPORTED_METHOD"); + return new SQLQueryAdapter(sb.toString(), errors, true); + } +} diff --git a/src/sqlancer/clickhouse/oracle/cert/ClickHouseCERTOracle.java b/src/sqlancer/clickhouse/oracle/cert/ClickHouseCERTOracle.java index 585fce95b..bb7183b8f 100644 --- a/src/sqlancer/clickhouse/oracle/cert/ClickHouseCERTOracle.java +++ b/src/sqlancer/clickhouse/oracle/cert/ClickHouseCERTOracle.java @@ -57,7 +57,8 @@ public void check() throws SQLException { queryPlan1Sequences = new ArrayList<>(); queryPlan2Sequences = new ArrayList<>(); - List tables = state.getSchema().getRandomTableNonEmptyTables().getTables(); + List tables = state.getSchema().getRandomTableNonEmptyTables().getTables().stream() + .filter(t -> !t.isView()).collect(Collectors.toList()); if (tables.isEmpty()) { throw new IgnoreMeException(); } diff --git a/src/sqlancer/clickhouse/oracle/codec/ClickHouseCodecRoundtripOracle.java b/src/sqlancer/clickhouse/oracle/codec/ClickHouseCodecRoundtripOracle.java new file mode 100644 index 000000000..3f5631228 --- /dev/null +++ b/src/sqlancer/clickhouse/oracle/codec/ClickHouseCodecRoundtripOracle.java @@ -0,0 +1,367 @@ +package sqlancer.clickhouse.oracle.codec; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicLong; + +import sqlancer.ComparatorHelper; +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.common.oracle.TestOracle; +import sqlancer.common.query.ExpectedErrors; +import sqlancer.common.query.SQLQueryAdapter; + +public class ClickHouseCodecRoundtripOracle implements TestOracle { + + private static final int DIFF_LIMIT = 20; + private static final AtomicLong CODEC_COUNTER = new AtomicLong(); + + private static final List GENERIC_CODECS = List.of("NONE", "LZ4", "LZ4HC(6)", "ZSTD(1)", "ZSTD(6)"); + private static final List INTEGRAL_CODECS = List.of("Delta(8), LZ4", "Delta(4), ZSTD(1)", "DoubleDelta, LZ4", + "T64, LZ4", "T64, ZSTD(1)", "Gorilla, LZ4"); + private static final List FLOAT_CODECS = List.of("Gorilla, LZ4", "FPC, LZ4", "ALP, LZ4", "ZSTD(3)"); + private static final List DATETIME_CODECS = List.of("Delta(4), LZ4", "DoubleDelta, LZ4", "T64, LZ4"); + + private static final List LOSSY_FLOAT_CODECS = List.of("SZ3", "ZXC"); + + private static final List INT_VALUES = List.of("0", "-1", "1", "127", "-128", "2147483647", "-2147483648", + "9223372036854775807", "-9223372036854775808", "42", "1000000"); + private static final List FLOAT_VALUES = List.of("nan", "inf", "-inf", "0", "-0.0", "1.5", "-1.5", "3.14", + "-3.14", "1e300", "-1e300", "5e-324"); + private static final List STRING_VALUES = List.of("''", "'a'", "'alpha'", "'0'", "'0.0'", + "' spaced '", "'zzzzzzzzzzzzzzzzzzzzzzzz'"); + private static final List DATETIME_VALUES = List.of("toDateTime('1970-01-01 00:00:00')", + "toDateTime('2000-02-29 12:00:00')", "toDateTime('2106-02-07 06:28:15')", + "toDateTime('2026-08-15 13:37:00')"); + + private final ClickHouseGlobalState state; + private final ExpectedErrors ddlErrors = new ExpectedErrors(); + private final ExpectedErrors readErrors = new ExpectedErrors(); + + public ClickHouseCodecRoundtripOracle(ClickHouseGlobalState state) { + this.state = state; + for (ExpectedErrors e : List.of(ddlErrors, readErrors)) { + ClickHouseErrors.addSessionSettingsErrors(e); + e.add("UNKNOWN_TABLE"); + e.add("Unknown table expression identifier"); + e.add("(MEMORY_LIMIT_EXCEEDED)"); + e.add("memory limit exceeded"); + e.add("TIMEOUT_EXCEEDED"); + e.add("Timeout exceeded"); + e.add("Limit for result exceeded"); + e.add("TOO_MANY_ROWS_OR_BYTES"); + } + ddlErrors.add("UNKNOWN_CODEC"); + ddlErrors.add("Unknown codec family code"); + ddlErrors.add("ILLEGAL_SYNTAX_FOR_CODEC_TYPE"); + ddlErrors.add("ILLEGAL_CODEC_PARAMETER"); + ddlErrors.add("BAD_ARGUMENTS"); + ddlErrors.add("SUPPORT_IS_DISABLED"); + ddlErrors.add("NOT_IMPLEMENTED"); + ddlErrors.add("is not applicable"); + ddlErrors.add("Exception happened during execution of mutation"); + ddlErrors.add("UNFINISHED"); + } + + private static final class Column { + private final String name; + private final String type; + private final String codec; + private final boolean lossy; + + private Column(String name, String type, String codec, boolean lossy) { + this.name = name; + this.type = type; + this.codec = codec; + this.lossy = lossy; + } + } + + @Override + public void check() throws SQLException { + if (!state.getClickHouseOptions().codecRoundtripOracle) { + throw new IgnoreMeException(); + } + + long id = CODEC_COUNTER.incrementAndGet(); + String db = state.getDatabaseName(); + String coded = db + ".codec_a_" + id; + String mirror = db + ".codec_b_" + id; + + List columns = pickColumns(); + boolean anyLossy = columns.stream().anyMatch(c -> c.lossy); + boolean adaptiveCodecs = Randomly.getBoolean(); + + try { + String createCoded = renderCreate(coded, columns, false, adaptiveCodecs); + log(createCoded); + if (!new SQLQueryAdapter(createCoded, ddlErrors, true).execute(state)) { + if (!anyLossy) { + throw new IgnoreMeException(); + } + columns = withLosslessFloatCodec(columns); + anyLossy = false; + createCoded = renderCreate(coded, columns, false, adaptiveCodecs); + log(createCoded); + if (!new SQLQueryAdapter(createCoded, ddlErrors, true).execute(state)) { + throw new IgnoreMeException(); + } + } + String createMirror = renderCreate(mirror, columns, true, false); + log(createMirror); + if (!new SQLQueryAdapter(createMirror, ddlErrors, true).execute(state)) { + throw new IgnoreMeException(); + } + + int blocks = 2 + (int) Randomly.getNotCachedInteger(0, 3); + long key = 0; + for (int b = 0; b < blocks; b++) { + int rows = 4 + (int) Randomly.getNotCachedInteger(0, 12); + String values = renderValues(columns, key, rows); + key += rows; + insertBoth(coded, mirror, values); + } + + compare(coded, mirror, columns, anyLossy, "after insert"); + + optimizeFinal(coded); + optimizeFinal(mirror); + compare(coded, mirror, columns, anyLossy, "after OPTIMIZE FINAL"); + + Column recoded = pickRecodableColumn(columns); + if (recoded != null) { + String newCodec = pickCodec(recoded.type); + String alter = "ALTER TABLE " + coded + " MODIFY COLUMN " + recoded.name + " " + recoded.type + + " CODEC(" + newCodec + ") SETTINGS mutations_sync = 2, alter_sync = 2"; + log(alter); + if (new SQLQueryAdapter(alter, ddlErrors, false).execute(state)) { + compare(coded, mirror, columns, anyLossy, "after MODIFY COLUMN CODEC(" + newCodec + ")"); + } + } + } finally { + dropQuietly(coded); + dropQuietly(mirror); + } + } + + private List pickColumns() { + List columns = new ArrayList<>(); + columns.add(new Column("k", "Int64", "NONE", false)); + columns.add(new Column("i64", "Int64", pickCodec("Int64"), false)); + columns.add(new Column("s", "String", pickCodec("String"), false)); + columns.add(new Column("d", "DateTime", pickCodec("DateTime"), false)); + boolean lossy = Randomly.getBooleanWithSmallProbability(); + String floatCodec = lossy ? Randomly.fromList(LOSSY_FLOAT_CODECS) : pickCodec("Float64"); + columns.add(new Column("f64", "Float64", floatCodec, lossy)); + return columns; + } + + private static String pickCodec(String type) { + List options = new ArrayList<>(GENERIC_CODECS); + switch (type) { + case "Int64": + options.addAll(INTEGRAL_CODECS); + break; + case "Float64": + options.addAll(FLOAT_CODECS); + break; + case "DateTime": + options.addAll(DATETIME_CODECS); + break; + default: + break; + } + return Randomly.fromList(options); + } + + private static List withLosslessFloatCodec(List columns) { + List out = new ArrayList<>(); + for (Column c : columns) { + out.add(c.lossy ? new Column(c.name, c.type, pickCodec(c.type), false) : c); + } + return out; + } + + private static Column pickRecodableColumn(List columns) { + List candidates = new ArrayList<>(); + for (Column c : columns) { + if (!c.lossy && !"k".equals(c.name)) { + candidates.add(c); + } + } + return candidates.isEmpty() ? null : Randomly.fromList(candidates); + } + + private static String renderCreate(String table, List columns, boolean mirror, + boolean adaptiveCodecSelection) { + StringBuilder sb = new StringBuilder("CREATE TABLE ").append(table).append(" ("); + for (int i = 0; i < columns.size(); i++) { + Column c = columns.get(i); + if (i > 0) { + sb.append(", "); + } + sb.append(c.name).append(' ').append(c.type).append(" CODEC(").append(mirror ? "NONE" : c.codec) + .append(')'); + } + sb.append(") ENGINE = MergeTree ORDER BY k SETTINGS index_granularity = 8"); + if (!mirror && adaptiveCodecSelection) { + sb.append(", allow_experimental_adaptive_codec_selection = 1"); + } + return sb.toString(); + } + + private static String renderValues(List columns, long startKey, int rows) { + StringBuilder sb = new StringBuilder(); + for (int r = 0; r < rows; r++) { + if (r > 0) { + sb.append(", "); + } + sb.append('('); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) { + sb.append(", "); + } + Column c = columns.get(i); + if ("k".equals(c.name)) { + sb.append(startKey + r); + continue; + } + switch (c.type) { + case "Int64": + sb.append(Randomly.fromList(INT_VALUES)); + break; + case "Float64": + sb.append(Randomly.fromList(FLOAT_VALUES)); + break; + case "String": + sb.append(Randomly.fromList(STRING_VALUES)); + break; + default: + sb.append(Randomly.fromList(DATETIME_VALUES)); + break; + } + } + sb.append(')'); + } + return sb.toString(); + } + + private void insertBoth(String coded, String mirror, String values) throws SQLException { + for (String table : List.of(coded, mirror)) { + String insert = "INSERT INTO " + table + " VALUES " + values; + log(insert); + if (!new SQLQueryAdapter(insert, ddlErrors, true).execute(state)) { + throw new IgnoreMeException(); + } + } + } + + private void optimizeFinal(String table) throws SQLException { + String sql = "OPTIMIZE TABLE " + table + " FINAL"; + log(sql); + new SQLQueryAdapter(sql, ddlErrors, false).execute(state); + } + + private void compare(String coded, String mirror, List columns, boolean anyLossy, String stage) + throws SQLException { + List losslessNames = new ArrayList<>(); + for (Column c : columns) { + if (!c.lossy) { + losslessNames.add(c.name); + } + } + String projection = "toString(tuple(" + String.join(", ", losslessNames) + "))"; + String codedSql = "SELECT " + projection + " FROM " + coded; + String mirrorSql = "SELECT " + projection + " FROM " + mirror; + log(codedSql); + List codedRows = ComparatorHelper.getResultSetFirstColumnAsString(codedSql, readErrors, state); + log(mirrorSql); + List mirrorRows = ComparatorHelper.getResultSetFirstColumnAsString(mirrorSql, readErrors, state); + + List diff = multisetDiff(mirrorRows, codedRows, DIFF_LIMIT); + if (!diff.isEmpty()) { + throw new AssertionError(String.format( + "codec roundtrip mismatch %s: the CODEC(NONE) mirror returned %d rows and the coded table returned " + + "%d rows for the same inserted data.%ncodecs: %s%nmirror: %s%ncoded: %s%n" + + "first %d differing rows: %s", + stage, mirrorRows.size(), codedRows.size(), describeCodecs(columns), mirrorSql, codedSql, + diff.size(), diff)); + } + + if (anyLossy) { + compareLossyShape(coded, mirror, columns, stage); + } + } + + private void compareLossyShape(String coded, String mirror, List columns, String stage) + throws SQLException { + for (Column c : columns) { + if (!c.lossy) { + continue; + } + String projection = "toString(tuple(count(), countIf(isNull(" + c.name + "))))"; + String codedSql = "SELECT " + projection + " FROM " + coded; + String mirrorSql = "SELECT " + projection + " FROM " + mirror; + log(codedSql); + List codedRows = ComparatorHelper.getResultSetFirstColumnAsString(codedSql, readErrors, state); + log(mirrorSql); + List mirrorRows = ComparatorHelper.getResultSetFirstColumnAsString(mirrorSql, readErrors, state); + if (!codedRows.equals(mirrorRows)) { + throw new AssertionError(String.format( + "lossy codec changed row count or NULL mask %s: column %s CODEC(%s) reports %s, the " + + "CODEC(NONE) mirror reports %s.%ncoded: %s%nmirror: %s", + stage, c.name, c.codec, codedRows, mirrorRows, codedSql, mirrorSql)); + } + } + } + + private static String describeCodecs(List columns) { + Map byName = new LinkedHashMap<>(); + for (Column c : columns) { + byName.put(c.name, c.codec); + } + return byName.toString(); + } + + private void log(String sql) { + if (state.getOptions().logEachSelect()) { + state.getLogger().writeCurrent(sql); + state.getState().logStatement(sql); + } + } + + private void dropQuietly(String table) { + try { + new SQLQueryAdapter("DROP TABLE IF EXISTS " + table, ddlErrors, true).execute(state); + } catch (Exception | AssertionError ignored) { + } + } + + private static List multisetDiff(List a, List b, int limit) { + Map counts = new TreeMap<>(); + for (String s : a) { + counts.merge(s == null ? "\\N" : s, 1L, Long::sum); + } + for (String s : b) { + counts.merge(s == null ? "\\N" : s, -1L, Long::sum); + } + List diff = new ArrayList<>(); + for (Map.Entry e : counts.entrySet()) { + if (e.getValue() == 0) { + continue; + } + if (diff.size() >= limit) { + break; + } + long c = e.getValue(); + diff.add(e.getKey() + " (+" + Math.abs(c) + " " + (c > 0 ? "mirror" : "coded") + ")"); + } + return diff; + } +} diff --git a/src/sqlancer/clickhouse/oracle/distributed/ClickHouseDistributedPlanEquivalenceOracle.java b/src/sqlancer/clickhouse/oracle/distributed/ClickHouseDistributedPlanEquivalenceOracle.java new file mode 100644 index 000000000..a4b076cc2 --- /dev/null +++ b/src/sqlancer/clickhouse/oracle/distributed/ClickHouseDistributedPlanEquivalenceOracle.java @@ -0,0 +1,258 @@ +package sqlancer.clickhouse.oracle.distributed; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.UnaryOperator; + +import sqlancer.ComparatorHelper; +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.common.oracle.TestOracle; +import sqlancer.common.query.ExpectedErrors; +import sqlancer.common.query.SQLQueryAdapter; + +public class ClickHouseDistributedPlanEquivalenceOracle implements TestOracle { + + private static final int DIFF_LIMIT = 20; + private static final AtomicLong DPE_COUNTER = new AtomicLong(); + + private static final String PARALLEL_REPLICAS_SETTINGS = "enable_parallel_replicas = 1, max_parallel_replicas = 3, " + + "cluster_for_parallel_replicas = 'default', parallel_replicas_for_non_replicated_merge_tree = 1"; + + enum Shape { + FULL_READ, GROUP_AGG, JOIN_WITH_VIEW, IN_SUBQUERY, ORDER_BY_LIMIT + } + + private static final class Profile { + private final String label; + private final UnaryOperator relation; + private final String settings; + + private Profile(String label, UnaryOperator relation, String settings) { + this.label = label; + this.relation = relation; + this.settings = settings; + } + } + + private final ClickHouseGlobalState state; + private final ExpectedErrors ddlErrors = new ExpectedErrors(); + private final ExpectedErrors readErrors = new ExpectedErrors(); + + public ClickHouseDistributedPlanEquivalenceOracle(ClickHouseGlobalState state) { + this.state = state; + for (ExpectedErrors e : List.of(ddlErrors, readErrors)) { + ClickHouseErrors.addExpectedExpressionErrors(e); + ClickHouseErrors.addSessionSettingsErrors(e); + + e.add("UNKNOWN_TABLE"); + e.add("Unknown table expression identifier"); + e.add("UNKNOWN_STORAGE"); + e.add("Unknown table engine"); + e.add("SUPPORT_IS_DISABLED"); + e.add("NOT_IMPLEMENTED"); + + e.add("(MEMORY_LIMIT_EXCEEDED)"); + e.add("memory limit exceeded"); + e.add("TIMEOUT_EXCEEDED"); + e.add("Timeout exceeded"); + e.add("Limit for result exceeded"); + e.add("TOO_MANY_ROWS_OR_BYTES"); + + e.add("Requested cluster"); + e.add("CLUSTER_DOESNT_EXIST"); + e.add("There is no Cluster"); + e.add("NETWORK_ERROR"); + e.add("Connection refused"); + e.add("All connection tries failed"); + e.add("ACCESS_DENIED"); + e.add("Not enough privileges"); + e.add("TOO_MANY_SIMULTANEOUS_QUERIES"); + } + + readErrors.add("parallel replicas"); + readErrors.add("Parallel replicas"); + readErrors.add("PARALLEL_REPLICAS_UNAVAILABLE"); + readErrors.add("distributed plan"); + readErrors.add("Distributed plan"); + readErrors.add("serialize_query_plan"); + readErrors.add("QUERY_PLAN_SERIALIZATION_ERROR"); + readErrors.add("ALIAS_REQUIRED"); + readErrors.add("INCORRECT_QUERY"); + } + + @Override + public void check() throws SQLException { + if (!state.getClickHouseOptions().distributedPlanEquivalenceOracle) { + throw new IgnoreMeException(); + } + + long id = DPE_COUNTER.incrementAndGet(); + String db = state.getDatabaseName(); + String factBare = "dpe_fact_" + id; + String dimBare = "dpe_dim_" + id; + String viewBare = "dpe_v_" + id; + String distFactBare = "dpe_dist_fact_" + id; + String distDimBare = "dpe_dist_dim_" + id; + String distViewBare = "dpe_dist_v_" + id; + + List created = new ArrayList<>(); + try { + int groups = 2 + (int) Randomly.getNotCachedInteger(0, 8); + int rowsPerBlock = 200 + (int) Randomly.getNotCachedInteger(0, 800); + int blocks = 2 + (int) Randomly.getNotCachedInteger(0, 3); + + execOrIgnore("CREATE TABLE " + db + "." + factBare + + " (id UInt32, g UInt32, v Int64) ENGINE = MergeTree ORDER BY id", created, factBare); + execOrIgnore("CREATE TABLE " + db + "." + dimBare + + " (id UInt32, g UInt32, v Int64) ENGINE = MergeTree ORDER BY id", created, dimBare); + for (int b = 0; b < blocks; b++) { + long offset = (long) b * rowsPerBlock; + execOrIgnore("INSERT INTO " + db + "." + factBare + " SELECT toUInt32(number + " + offset + + ") AS id, toUInt32(number % " + groups + ") AS g, toInt64(number % 100) AS v FROM numbers(" + + rowsPerBlock + ")", null, null); + } + execOrIgnore("INSERT INTO " + db + "." + dimBare + " SELECT toUInt32(number) AS id, toUInt32(number % " + + groups + ") AS g, toInt64(number % 7) AS v FROM numbers(" + groups + ")", null, null); + + execOrIgnore("CREATE VIEW " + db + "." + viewBare + " AS SELECT id, g, v FROM " + db + "." + dimBare, + created, viewBare); + + execOrIgnore("CREATE TABLE " + db + "." + distFactBare + " AS " + db + "." + factBare + + " ENGINE = Distributed('default', currentDatabase(), '" + factBare + "')", created, + distFactBare); + execOrIgnore("CREATE TABLE " + db + "." + distDimBare + " AS " + db + "." + dimBare + + " ENGINE = Distributed('default', currentDatabase(), '" + dimBare + "')", created, distDimBare); + execOrIgnore("CREATE TABLE " + db + "." + distViewBare + " AS " + db + "." + viewBare + + " ENGINE = Distributed('default', currentDatabase(), '" + viewBare + "')", created, + distViewBare); + + Map distributedNames = Map.of(factBare, distFactBare, dimBare, distDimBare, viewBare, + distViewBare); + + UnaryOperator localRel = bare -> db + "." + bare; + UnaryOperator clusterRel = bare -> "cluster('default', currentDatabase(), '" + bare + "')"; + UnaryOperator distributedRel = bare -> db + "." + distributedNames.get(bare); + + List profiles = List.of(new Profile("make_distributed_plan", localRel, "make_distributed_plan = 1"), + new Profile("serialize_query_plan", localRel, "serialize_query_plan = 1"), + new Profile("cluster + local plan", clusterRel, "parallel_replicas_local_plan = 1"), + new Profile("cluster + no local plan", clusterRel, "parallel_replicas_local_plan = 0"), + new Profile("distributed + parallel replicas", distributedRel, PARALLEL_REPLICAS_SETTINGS), + new Profile("local + parallel replicas", localRel, PARALLEL_REPLICAS_SETTINGS)); + + Shape shape = Randomly.fromOptions(Shape.values()); + String baselineQuery = renderQuery(shape, localRel, factBare, dimBare, viewBare, null); + log(baselineQuery); + List baselineRows = ComparatorHelper.getResultSetFirstColumnAsString(baselineQuery, readErrors, + state); + + for (Profile profile : profiles) { + String query = renderQuery(shape, profile.relation, factBare, dimBare, viewBare, profile.settings); + List rows; + try { + log(query); + rows = ComparatorHelper.getResultSetFirstColumnAsString(query, readErrors, state); + } catch (IgnoreMeException e) { + continue; + } + assertMultisetsEqual(shape, profile.label, baselineQuery, baselineRows, query, rows); + } + } finally { + for (int i = created.size() - 1; i >= 0; i--) { + dropQuietly(db + "." + created.get(i)); + } + } + } + + private static String renderQuery(Shape shape, UnaryOperator rel, String fact, String dim, String view, + String settings) { + String suffix = settings == null ? "" : " SETTINGS " + settings; + switch (shape) { + case FULL_READ: + return "SELECT toString(tuple(id, g, v)) FROM " + rel.apply(fact) + suffix; + case GROUP_AGG: + return "SELECT toString(tuple(g, count(), sum(v), min(v), max(v))) FROM " + rel.apply(fact) + + " GROUP BY g ORDER BY g" + suffix; + case JOIN_WITH_VIEW: + return "SELECT toString(tuple(a.id, b.g, c.v)) FROM " + rel.apply(fact) + " AS a, " + rel.apply(view) + + " AS b, " + rel.apply(dim) + " AS c WHERE a.g = b.g AND b.id = c.id" + suffix; + case IN_SUBQUERY: + return "SELECT toString(tuple(id, g, v)) FROM " + rel.apply(fact) + " WHERE g IN (SELECT g FROM " + + rel.apply(view) + " WHERE v >= 2)" + suffix; + case ORDER_BY_LIMIT: + return "SELECT toString(tuple(id, g, v)) FROM " + rel.apply(fact) + " ORDER BY id ASC, g ASC, v ASC " + + "LIMIT 50" + suffix; + default: + throw new AssertionError(shape); + } + } + + private void execOrIgnore(String sql, List created, String createdBareName) throws SQLException { + log(sql); + if (!new SQLQueryAdapter(sql, ddlErrors, true).execute(state)) { + throw new IgnoreMeException(); + } + if (created != null) { + created.add(createdBareName); + } + } + + private void log(String sql) { + if (state.getOptions().logEachSelect()) { + state.getLogger().writeCurrent(sql); + state.getState().logStatement(sql); + } + } + + private void dropQuietly(String name) { + try { + new SQLQueryAdapter("DROP TABLE IF EXISTS " + name, ddlErrors, true).execute(state); + } catch (Exception | AssertionError ignored) { + } + try { + new SQLQueryAdapter("DROP VIEW IF EXISTS " + name, ddlErrors, true).execute(state); + } catch (Exception | AssertionError ignored) { + } + } + + private static void assertMultisetsEqual(Shape shape, String label, String baselineQuery, + List baselineRows, String query, List rows) { + List diff = multisetDiff(baselineRows, rows, DIFF_LIMIT); + if (diff.isEmpty()) { + return; + } + throw new AssertionError(String.format( + "distributed-plan equivalence mismatch (%s shape, %s profile): %d baseline rows vs %d profile rows." + + "%nbaseline: %s%nprofile: %s%nfirst %d differing entries: %s", + shape, label, baselineRows.size(), rows.size(), baselineQuery, query, diff.size(), diff)); + } + + private static List multisetDiff(List a, List b, int limit) { + Map counts = new TreeMap<>(); + for (String s : a) { + counts.merge(s == null ? "\\N" : s, 1L, Long::sum); + } + for (String s : b) { + counts.merge(s == null ? "\\N" : s, -1L, Long::sum); + } + List diff = new ArrayList<>(); + for (Map.Entry e : counts.entrySet()) { + if (e.getValue() == 0) { + continue; + } + if (diff.size() >= limit) { + break; + } + long c = e.getValue(); + diff.add(e.getKey() + " (+" + Math.abs(c) + " " + (c > 0 ? "baseline" : "profile") + ")"); + } + return diff; + } +} diff --git a/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinReorderOracle.java b/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinReorderOracle.java index 2af72a930..d4856640f 100644 --- a/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinReorderOracle.java +++ b/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinReorderOracle.java @@ -27,6 +27,11 @@ public class ClickHouseJoinReorderOracle implements TestOracle JOIN_REORDER_ALGORITHMS = List.of("greedy", "dpsize", "dpsub", "dphyp", "dphyp,greedy", + "dpsub,greedy"); + + static final String ARM_NO_PLAN_OPTIMIZATIONS = "SETTINGS query_plan_enable_optimizations = 0"; + enum JoinKind { INNER("INNER JOIN"), LEFT("LEFT JOIN"), FULL("FULL JOIN"), LEFT_SEMI("LEFT SEMI JOIN"), LEFT_ANTI("LEFT ANTI JOIN"), RIGHT_SEMI("RIGHT SEMI JOIN"), RIGHT_ANTI("RIGHT ANTI JOIN"); @@ -90,11 +95,12 @@ static boolean referencesDroppedAlias(List kinds, List onLeft private final ExpectedErrors readErrors = new ExpectedErrors(); private final ExpectedErrors selectErrors = new ExpectedErrors(); private final ExpectedErrors statsErrors = new ExpectedErrors(); + private final ExpectedErrors algorithmErrors = new ExpectedErrors(); public ClickHouseJoinReorderOracle(ClickHouseGlobalState state) { this.state = state; - for (ExpectedErrors e : List.of(readErrors, selectErrors, statsErrors)) { + for (ExpectedErrors e : List.of(readErrors, selectErrors, statsErrors, algorithmErrors)) { ClickHouseErrors.addSessionSettingsErrors(e); @@ -109,6 +115,12 @@ public ClickHouseJoinReorderOracle(ClickHouseGlobalState state) { } selectErrors.add("Join restriction violated"); + selectErrors.addAll(ClickHouseErrors.getKnownOpenJoinOrderBugs()); + + algorithmErrors.add("Join restriction violated"); + algorithmErrors.addAll(ClickHouseErrors.getKnownOpenJoinOrderBugs()); + algorithmErrors.add("Failed to find a valid join order"); + algorithmErrors.add("EXPERIMENTAL_FEATURE_ERROR"); ClickHouseErrors.addStatisticsErrors(statsErrors); statsErrors.add("already contains statistics"); @@ -140,6 +152,9 @@ public void check() throws SQLException { nullableKey[1] = true; } + String viewRelation = null; + int viewRelationIndex = 1 + (int) Randomly.getNotCachedInteger(0, numTables - 1); + try { for (int i = 0; i < numTables; i++) { String keyType = nullableKey[i] ? "Nullable(Int32)" : "Int32"; @@ -152,6 +167,16 @@ public void check() throws SQLException { } seedTables(tables, nullableKey); + if (Randomly.getNotCachedInteger(0, 100) < 40) { + String candidate = db + ".jreord_" + id + "_v" + viewRelationIndex; + String createView = "CREATE VIEW " + candidate + " AS SELECT k, v, s FROM " + + tables.get(viewRelationIndex); + logStmt(createView); + if (new SQLQueryAdapter(createView, readErrors, true).execute(state)) { + viewRelation = candidate; + } + } + if (Randomly.getNotCachedInteger(0, 100) < 25) { materializeStatsBestEffort(tables.get(0)); } @@ -181,9 +206,14 @@ public void check() throws SQLException { where = "a" + aIdx + ".v " + Randomly.fromOptions("<", "<=", "!=") + " a" + bIdx + ".v"; } - String qOn = renderQuery(kinds, tables, onLeft, where, ARM_REORDER_ON); - String qOff = renderQuery(kinds, tables, onLeft, where, ARM_REORDER_OFF); - String qRandomized = renderQuery(kinds, tables, onLeft, where, ARM_REORDER_RANDOMIZE); + List relations = new ArrayList<>(tables); + if (viewRelation != null) { + relations.set(viewRelationIndex, viewRelation); + } + + String qOn = renderQuery(kinds, relations, onLeft, where, ARM_REORDER_ON); + String qOff = renderQuery(kinds, relations, onLeft, where, ARM_REORDER_OFF); + String qRandomized = renderQuery(kinds, relations, onLeft, where, ARM_REORDER_RANDOMIZE); logStmt(qOn); List rowsOn = ComparatorHelper.getResultSetFirstColumnAsString(qOn, selectErrors, state); @@ -196,7 +226,16 @@ public void check() throws SQLException { assertMultisetsEqual(kinds, qOn, rowsOn, qOff, rowsOff); assertMultisetsEqual(kinds, qOn, rowsOn, qRandomized, rowsRandomized); assertMultisetsEqual(kinds, qOff, rowsOff, qRandomized, rowsRandomized); + + checkEnumerationAlgorithms(kinds, relations, onLeft, where, qOn, rowsOn); } finally { + if (viewRelation != null) { + try { + new SQLQueryAdapter("DROP VIEW IF EXISTS " + viewRelation, readErrors, true).execute(state); + } catch (Exception | AssertionError ignored) { + + } + } for (String t : tables) { try { new SQLQueryAdapter("DROP TABLE IF EXISTS " + t, readErrors, true).execute(state); @@ -207,6 +246,30 @@ public void check() throws SQLException { } } + private void checkEnumerationAlgorithms(List kinds, List relations, List onLeft, + String where, String referenceQuery, List referenceRows) throws SQLException { + List arms = new ArrayList<>(); + for (String algorithm : JOIN_REORDER_ALGORITHMS) { + arms.add(ARM_REORDER_ON + ", query_plan_optimize_join_order_algorithm = '" + algorithm + "'"); + } + arms.add(ARM_NO_PLAN_OPTIMIZATIONS); + arms.add(ARM_REORDER_ON + ", query_plan_enable_optimizations = 0"); + arms.add(ARM_REORDER_ON + ", query_plan_join_shard_by_pk_ranges = 1"); + arms.add(ARM_REORDER_ON + ", query_plan_optimize_join_order_max_searched_plans = 1"); + + for (String arm : arms) { + String query = renderQuery(kinds, relations, onLeft, where, arm); + List rows; + try { + logStmt(query); + rows = ComparatorHelper.getResultSetFirstColumnAsString(query, algorithmErrors, state); + } catch (IgnoreMeException e) { + continue; + } + assertMultisetsEqual(kinds, referenceQuery, referenceRows, query, rows); + } + } + private void seedTables(List tables, boolean[] nullableKey) throws SQLException { long bigRows = 200 + Randomly.getNotCachedInteger(0, 201); diff --git a/src/sqlancer/clickhouse/oracle/keycond/ClickHouseFloatPruningOracle.java b/src/sqlancer/clickhouse/oracle/keycond/ClickHouseFloatPruningOracle.java new file mode 100644 index 000000000..d4137318b --- /dev/null +++ b/src/sqlancer/clickhouse/oracle/keycond/ClickHouseFloatPruningOracle.java @@ -0,0 +1,312 @@ +package sqlancer.clickhouse.oracle.keycond; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicLong; + +import sqlancer.ComparatorHelper; +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.common.oracle.TestOracle; +import sqlancer.common.query.ExpectedErrors; +import sqlancer.common.query.SQLQueryAdapter; + +public class ClickHouseFloatPruningOracle implements TestOracle { + + private static final int DIFF_LIMIT = 20; + private static final AtomicLong FP_COUNTER = new AtomicLong(); + + private static final List FLOAT_LITERALS = List.of("nan", "inf", "-inf", "0", "-0.0", "1.5", "-1.5", "100", + "-100", "3.14", "-3.14", "0.0000001"); + + private static final List COMPARISON_LITERALS = List.of("0", "-0.0", "1.5", "-1.5", "3.14", "-3.14", "100", + "nan", "inf", "-inf"); + + private static final String FLOAT_COLUMN_F32 = "f32"; + private static final String FLOAT_COLUMN_F64 = "f64"; + private static final String FLOAT_COLUMN_NF64 = "nf64"; + + private static final String PRUNING_OFF = "use_skip_indexes = 0, use_skip_indexes_on_data_read = 0, " + + "allow_statistics_optimize = 0, use_query_condition_cache = 0, optimize_move_to_prewhere = 0, " + + "convert_query_to_cnf = 0, force_primary_key = 0, optimize_use_implicit_projections = 0"; + + private final ClickHouseGlobalState state; + private final ExpectedErrors ddlErrors = new ExpectedErrors(); + private final ExpectedErrors readErrors = new ExpectedErrors(); + private final ExpectedErrors statsErrors = new ExpectedErrors(); + + public ClickHouseFloatPruningOracle(ClickHouseGlobalState state) { + this.state = state; + for (ExpectedErrors e : List.of(ddlErrors, readErrors, statsErrors)) { + ClickHouseErrors.addSessionSettingsErrors(e); + e.add("UNKNOWN_TABLE"); + e.add("Unknown table expression identifier"); + e.add("(MEMORY_LIMIT_EXCEEDED)"); + e.add("memory limit exceeded"); + e.add("TIMEOUT_EXCEEDED"); + e.add("Timeout exceeded"); + e.add("Limit for result exceeded"); + e.add("TOO_MANY_ROWS_OR_BYTES"); + } + ddlErrors.add("Floating point partition key is not supported"); + ddlErrors.add("allow_floating_point_partition_key"); + ddlErrors.add("TOO_MANY_PARTS"); + ddlErrors.add("Too many partitions"); + ClickHouseErrors.addStatisticsErrors(statsErrors); + statsErrors.add("already contains statistics"); + statsErrors.add("Exception happened during execution of mutation"); + statsErrors.add("UNFINISHED"); + } + + @Override + public void check() throws SQLException { + if (!state.getClickHouseOptions().floatPruningOracle) { + throw new IgnoreMeException(); + } + + long id = FP_COUNTER.incrementAndGet(); + String table = state.getDatabaseName() + ".fprune_" + id; + try { + if (!createFixture(table)) { + throw new IgnoreMeException(); + } + seedFixture(table); + if (Randomly.getBoolean()) { + materializeStatisticsBestEffort(table); + } + + String predicate = buildPredicate(); + checkRowSetEquivalence(table, predicate); + checkTruthValuePartition(table, predicate); + } finally { + dropQuietly(table); + } + } + + private boolean createFixture(String table) throws SQLException { + StringBuilder sb = new StringBuilder("CREATE TABLE ").append(table) + .append(" (k Int64, f32 Float32, f64 Float64, nf64 Nullable(Float64)"); + if (Randomly.getBoolean()) { + sb.append(", INDEX idx_f64 f64 TYPE minmax GRANULARITY 1"); + } + if (Randomly.getBoolean()) { + sb.append(", INDEX idx_f32 f32 TYPE bloom_filter(0.01) GRANULARITY 1"); + } + if (Randomly.getBoolean()) { + sb.append(", INDEX idx_nf64 nf64 TYPE minmax GRANULARITY 1"); + } + sb.append(") ENGINE = MergeTree ORDER BY "); + sb.append(Randomly.fromOptions("(f64, k)", "(f32, k)", "k", "(nf64, k)")); + boolean floatPartition = Randomly.getBoolean(); + if (floatPartition) { + sb.append(" PARTITION BY ").append(Randomly.fromOptions("f32", "f64")); + } + sb.append(" SETTINGS index_granularity = 8, allow_nullable_key = 1, allow_suspicious_indices = 1"); + if (floatPartition) { + sb.append(", allow_floating_point_partition_key = 1"); + } + String create = sb.toString(); + log(create); + return new SQLQueryAdapter(create, ddlErrors, true).execute(state); + } + + private void seedFixture(String table) throws SQLException { + int blocks = 3 + (int) Randomly.getNotCachedInteger(0, 3); + long key = 0; + for (int b = 0; b < blocks; b++) { + boolean allNaNBlock = b == 1; + int rows = 4 + (int) Randomly.getNotCachedInteger(0, 12); + StringBuilder values = new StringBuilder(); + for (int r = 0; r < rows; r++) { + if (r > 0) { + values.append(", "); + } + String f32 = allNaNBlock ? "nan" : Randomly.fromList(FLOAT_LITERALS); + String f64 = allNaNBlock ? "nan" : Randomly.fromList(FLOAT_LITERALS); + String nf64; + if (allNaNBlock) { + nf64 = "nan"; + } else { + nf64 = Randomly.getBooleanWithRatherLowProbability() ? "NULL" : Randomly.fromList(FLOAT_LITERALS); + } + values.append("(").append(key++).append(", ").append(f32).append(", ").append(f64).append(", ") + .append(nf64).append(")"); + } + String insert = "INSERT INTO " + table + " VALUES " + values; + log(insert); + if (!new SQLQueryAdapter(insert, ddlErrors, true).execute(state)) { + throw new IgnoreMeException(); + } + } + } + + private void materializeStatisticsBestEffort(String table) throws SQLException { + String column = Randomly.fromOptions(FLOAT_COLUMN_F32, FLOAT_COLUMN_F64); + String kind = Randomly.fromOptions("minmax", "tdigest", "minmax, tdigest"); + String add = "ALTER TABLE " + table + " ADD STATISTICS IF NOT EXISTS " + column + " TYPE " + kind + + " SETTINGS allow_experimental_statistics = 1"; + log(add); + if (!new SQLQueryAdapter(add, statsErrors, false).execute(state)) { + return; + } + String materialize = "ALTER TABLE " + table + " MATERIALIZE STATISTICS " + column + + " SETTINGS mutations_sync = 2, allow_experimental_statistics = 1"; + log(materialize); + new SQLQueryAdapter(materialize, statsErrors, false).execute(state); + } + + private String buildPredicate() { + String first = buildAtom(); + if (Randomly.getBoolean()) { + return first; + } + String second = buildAtom(); + String connective = Randomly.fromOptions(" AND ", " OR "); + String combined = "(" + first + ")" + connective + "(" + second + ")"; + return Randomly.getBoolean() ? combined : "NOT (" + combined + ")"; + } + + private String buildAtom() { + String column = Randomly.fromOptions(FLOAT_COLUMN_F32, FLOAT_COLUMN_F64, FLOAT_COLUMN_NF64); + String literal = Randomly.fromList(COMPARISON_LITERALS); + int form = (int) Randomly.getNotCachedInteger(0, 9); + switch (form) { + case 0: + return "NOT (%" + column + "% < " + literal + ")"; + case 1: + return "NOT (%" + column + "% > " + literal + ")"; + case 2: + return "NOT (%" + column + "% <= " + literal + ")"; + case 3: + return "NOT (%" + column + "% >= " + literal + ")"; + case 4: + return "NOT (%" + column + "% BETWEEN " + literal + " AND " + Randomly.fromList(COMPARISON_LITERALS) + ")"; + case 5: + return "NOT (NOT (%" + column + "% < " + literal + "))"; + case 6: + return "NOT (%" + column + "% = " + literal + ")"; + case 7: + return "%" + column + "% IS NULL"; + default: + return "%" + column + "% IS NOT NULL"; + } + } + + private static String render(String predicateTemplate, boolean materialized) { + String out = predicateTemplate; + for (String c : List.of(FLOAT_COLUMN_F32, FLOAT_COLUMN_F64, FLOAT_COLUMN_NF64)) { + out = out.replace("%" + c + "%", materialized ? "materialize(" + c + ")" : c); + } + return out; + } + + private void checkRowSetEquivalence(String table, String predicateTemplate) throws SQLException { + String pruningOn = "SETTINGS use_skip_indexes = 1, use_skip_indexes_on_data_read = 1, " + + "allow_statistics_optimize = " + (Randomly.getBoolean() ? 1 : 0) + ", convert_query_to_cnf = " + + (Randomly.getBoolean() ? 1 : 0) + ", optimize_move_to_prewhere = " + (Randomly.getBoolean() ? 1 : 0); + + String pruned = "SELECT toString(k) FROM " + table + " WHERE " + render(predicateTemplate, false) + " " + + pruningOn; + String scanned = "SELECT arrayStringConcat(arraySort(groupArrayIf(k, ifNull((" + render(predicateTemplate, true) + + "), 0))), ',') FROM " + table + " SETTINGS " + PRUNING_OFF; + + log(pruned); + List prunedRows = ComparatorHelper.getResultSetFirstColumnAsString(pruned, readErrors, state); + log(scanned); + List scannedRows = ComparatorHelper.getResultSetFirstColumnAsString(scanned, readErrors, state); + if (scannedRows.size() != 1) { + throw new IgnoreMeException(); + } + + List groundTruth = new ArrayList<>(); + String packed = scannedRows.get(0); + if (packed != null && !packed.isEmpty()) { + for (String part : packed.split(",")) { + groundTruth.add(part); + } + } + + List diff = multisetDiff(groundTruth, prunedRows, DIFF_LIMIT); + if (!diff.isEmpty()) { + throw new AssertionError(String.format( + "float pruning dropped or added rows: a full scan that evaluates the predicate as an aggregate " + + "argument (so nothing can be pruned) selects %d keys, the same predicate in WHERE " + + "selects %d, over a fixture containing NaN, +/-inf, -0.0 and NULL.%nfull scan: %s%n" + + "pruned: %s%nfirst %d differing keys: %s", + groundTruth.size(), prunedRows.size(), scanned, pruned, diff.size(), diff)); + } + } + + private void checkTruthValuePartition(String table, String predicateTemplate) throws SQLException { + String predicate = render(predicateTemplate, false); + String settings = Randomly.getBoolean() ? " SETTINGS convert_query_to_cnf = 1" : ""; + + long total = scalar("SELECT toString(count()) FROM " + table); + long truthy = scalar("SELECT toString(count()) FROM " + table + " WHERE (" + predicate + ")" + settings); + long falsy = scalar("SELECT toString(count()) FROM " + table + " WHERE NOT (" + predicate + ")" + settings); + long unknown = scalar( + "SELECT toString(count()) FROM " + table + " WHERE (" + predicate + ") IS NULL" + settings); + + if (truthy + falsy + unknown != total) { + throw new AssertionError(String.format( + "float pruning ternary partition violated: count(P)=%d + count(NOT P)=%d + count(P IS NULL)=%d " + + "= %d, but the table holds %d rows.%n table: %s%n P: %s%n settings:%s", + truthy, falsy, unknown, truthy + falsy + unknown, total, table, predicate, + settings.isEmpty() ? " (defaults)" : settings)); + } + } + + private long scalar(String query) throws SQLException { + log(query); + List rows = ComparatorHelper.getResultSetFirstColumnAsString(query, readErrors, state); + if (rows.size() != 1 || rows.get(0) == null) { + throw new IgnoreMeException(); + } + try { + return Long.parseLong(rows.get(0).trim()); + } catch (NumberFormatException e) { + throw new IgnoreMeException(); + } + } + + private void log(String sql) { + if (state.getOptions().logEachSelect()) { + state.getLogger().writeCurrent(sql); + state.getState().logStatement(sql); + } + } + + private void dropQuietly(String table) { + try { + new SQLQueryAdapter("DROP TABLE IF EXISTS " + table, ddlErrors, true).execute(state); + } catch (Exception | AssertionError ignored) { + } + } + + private static List multisetDiff(List a, List b, int limit) { + Map counts = new TreeMap<>(); + for (String s : a) { + counts.merge(s == null ? "\\N" : s, 1L, Long::sum); + } + for (String s : b) { + counts.merge(s == null ? "\\N" : s, -1L, Long::sum); + } + List diff = new ArrayList<>(); + for (Map.Entry e : counts.entrySet()) { + if (e.getValue() == 0) { + continue; + } + if (diff.size() >= limit) { + break; + } + long c = e.getValue(); + diff.add(e.getKey() + " (+" + Math.abs(c) + " " + (c > 0 ? "full scan" : "pruned") + ")"); + } + return diff; + } +} diff --git a/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java b/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java index 6ba4ffc22..cf2503d4f 100644 --- a/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java +++ b/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java @@ -41,6 +41,10 @@ public void check() throws SQLException { throw new IgnoreMeException(); } ClickHouseTable table = tables.get((int) Randomly.getNotCachedInteger(0, tables.size())); + if (table.isView()) { + + throw new IgnoreMeException(); + } ClickHouseTableReference tableRef = new ClickHouseTableReference(table, null); List columns = tableRef.getColumnReferences(); if (columns.size() < 2) { diff --git a/src/sqlancer/clickhouse/oracle/rowpolicy/ClickHouseRowPolicyOracle.java b/src/sqlancer/clickhouse/oracle/rowpolicy/ClickHouseRowPolicyOracle.java index 74624c674..bb839b06c 100644 --- a/src/sqlancer/clickhouse/oracle/rowpolicy/ClickHouseRowPolicyOracle.java +++ b/src/sqlancer/clickhouse/oracle/rowpolicy/ClickHouseRowPolicyOracle.java @@ -37,7 +37,8 @@ public ClickHouseRowPolicyOracle(ClickHouseGlobalState state) { @Override public void check() throws SQLException { ClickHouseSchema schema = state.getSchema(); - List tables = schema.getRandomTableNonEmptyTables().getTables(); + List tables = schema.getRandomTableNonEmptyTables().getTables().stream() + .filter(t -> !t.isView()).collect(java.util.stream.Collectors.toList()); if (tables.isEmpty()) { throw new IgnoreMeException(); } diff --git a/src/sqlancer/clickhouse/oracle/tlp/ClickHouseTLPBase.java b/src/sqlancer/clickhouse/oracle/tlp/ClickHouseTLPBase.java index b18910a3e..57b58bd99 100644 --- a/src/sqlancer/clickhouse/oracle/tlp/ClickHouseTLPBase.java +++ b/src/sqlancer/clickhouse/oracle/tlp/ClickHouseTLPBase.java @@ -138,7 +138,8 @@ public void check() throws SQLException { select.setFetchColumns(from); select.setWhereClause(null); - if (select.getJoinClauses().isEmpty() && Randomly.getBooleanWithRatherLowProbability()) { + if (select.getJoinClauses().isEmpty() && !table.getTable().isView() + && Randomly.getBooleanWithRatherLowProbability()) { select.setPrewhereClause(gen.generateExpressionWithColumns(columns, 3)); } From 18a41908170072c384045a4e07e5213e2bb007d5 Mon Sep 17 00:00:00 2001 From: Nikita Fomichev Date: Sun, 16 Aug 2026 00:11:34 +0200 Subject: [PATCH 3/4] docs(clickhouse): record the P0 batch, its validation, and three wrong plan assumptions Adds a "P0 coverage batch, 2026-08-15" section to the provider CLAUDE.md with per-item operational detail, and a "Known-open bugs the 2026-08-15 batch deliberately fires on" triage list so a future run recognises the expected noise instead of re-investigating it: - the unfiled NOT (NOT key) part-pruning wrong result, with the reason KeyCondition cannot catch it and NoREC/TLPWhere can; - ClickHouse#113417 / #112036, why FloatPruning is kept out of ALL_ORACLES; - ClickHouse#114113 and its pin. Marks items 0a, 0b and 1-6 done in the plan, flips its status to p0-implemented, and records where the plan was wrong so the P1/P2 entries are not written against the same false premises: - materialize() plus a pruning-off settings profile does not defeat partition-level or primary-key-level pruning, which invalidated the intended oracle for item 1 and the reference arm for item 2; - the join-order enumerator setting is query_plan_optimize_join_order_algorithm; - item 4 needed a persistent-view DDL action and a real ON-less CROSS join, not just a join-picker change. Validation on dev-VM head 26.8.1.1470: a 30-minute full-fleet run over all 94 oracles except TextIndexDirectRead finished exit 0, 137,380 queries, 0 reproducers, 0 threads shut down. A 12-minute run of the changed and new oracles did 36k queries with a single reproducer, and that one was the known unfiled NOT (NOT bug. --- .claude/CLAUDE.md | 144 ++++++++++++++++++ ...eat-clickhouse-4month-coverage-gap-plan.md | 44 ++++-- 2 files changed, 179 insertions(+), 9 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 55212e8ba..5f347936b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -106,6 +106,150 @@ ssh ubuntu@nik-fomichev-dev-vm-1 'cd ~/sqlancer-fork && \ Scaling on c7g.4xlarge (16 vCPU / 32 GiB): cap CH at 8 cpu / 6 GiB (`--cpus=8 -m=6g`) and run sqlancer with `--num-threads 8 -Xmx24g`. Totals out at ~30 GiB used, leaving ~2 GiB for the OS and container daemon. CH-side `MEMORY_LIMIT_EXCEEDED` is now globally tolerated (commit `15b8a901`), so the squeezed `-m=6g` cap surfaces as harmless `IgnoreMe`s rather than worker deaths — that's the trade for the bigger JVM heap. The earlier 8/16 split (CH at 10 cpu / 12 GiB, sqlancer at 8/16) also worked but left less GC margin for the heaviest iterations. CH at 12 cpu / 14 GiB + sqlancer at 12 threads / 12 GiB heap **overshoots** (per-thread heap drops below the 1.3 GiB floor) — attempt-1 of the 3h run died in 13 minutes that way. +## P0 coverage batch, 2026-08-15 (`docs/plans/2026-08-15-001-...-4month-coverage-gap-plan.md`) + +Items 0a, 0b, 1–6 of the 4-month coverage-gap audit. Validated on dev-vm head **26.8.1.1470**: +a 30-minute full-fleet run over all 94 oracles except `TextIndexDirectRead` (its #107186 flood +drowns everything else) finished **exit 0, 137,380 queries, 0 reproducers, 0 threads shut down**. +A separate 12-minute run of the changed and new oracles (CodecRoundtrip, +DistributedPlanEquivalence, JoinReorder, LimitRanking, ReplacingDedup, FinalMerge, +EngineEquivalence, PartitionMirror) did 36k queries with a single reproducer, and that one was the +known unfiled `NOT (NOT` bug below. + +**0a — LIMIT BY cap is now asserted server-side.** `ClickHouseLimitRankingOracle.checkLimitByCap` +used to pull the key column into Java through `ComparatorHelper.getResultSetFirstColumnAsString`, +which routes every value through `trimTrailingDotZeros`; that helper rewrites `'0.0'` into `'0'`, +so a String key holding both looked like one key appearing twice (the 2026-08-04/08-07 nightly +false positives). The check is now +`SELECT max(cnt) FROM (SELECT count() AS cnt FROM () GROUP BY lb_key)`. Nothing is +normalised client-side any more, and 10000 rows no longer cross the wire. **`trimTrailingDotZeros` +is still applied by every other oracle** — scoping or removing it is a separate, still-open +follow-up (checklist rule C8). + +**0b — degenerate dedupe ORDER BY keys are rejected.** `ClickHouseTableGenerator` gained +`hasDegenerateKeyDomain` / `isDedupeKeyColumn`: a dedupe or collapse engine's sorting key may no +longer be `Bool` or an `Enum` with fewer than `MIN_DEDUPE_KEY_DOMAIN` (8) entries — and the type +picker caps generated enums at 5 entries, so today that rejects every enum. `pickEngine` falls back +to plain MergeTree when no non-degenerate bare key column exists, the dedupe fallback ORDER BY uses +the same filter, and `ReplacingMergeTree` now **always** emits its ver argument (previously 50%). +With a two-value key a background merge collapses visible cardinality between two reads, which is +what produced the 08-07 `TLPWhere: size of the result sets mismatch (91 and 26)` false positive. +Verified on a 12-minute dev-vm run: 0 dedupe tables with a Bool/Enum sorting key, 0 of 75 +ReplacingMergeTree tables without a ver argument. + +**1 — boolean-position and truth-value predicates** (`--truth-value-predicate-emission`, default +on). `generatePredicate()` now emits `NOT (NOT x)`, `NOT x`, `x IS [NOT] TRUE/FALSE/UNKNOWN`, +`x IS NOT DISTINCT FROM lit`, `nullIf/ifNull/coalesce(x, lit)` over numeric columns, plus +`LIKE`/`ILIKE ... ESCAPE` over String columns; half the time the wrapper is compared against a +numeric or float constant, which is the *value position* that matters. Rendered through real AST +nodes (`ClickHouseUnaryPrefixOperation`, `ClickHousePostfixText`, and the new +`ClickHouseWrappedExpression`), never `ClickHouseRawText`, so the KeyCondition oracle's +`materialize()` rewrite still reaches the column references. **This finds a real, unfiled +wrong-result bug on head — see the entry below.** + +**2 — `FloatPruning` oracle** (`--float-pruning-oracle`). Private fixture with +Float32/Float64/Nullable(Float64) columns holding NaN, ±inf, -0.0 and NULL across several parts +(one part all-NaN), float ORDER BY / PARTITION BY / minmax + bloom_filter skip indexes / +materialized statistics. Two assertions: (a) a negated float comparison in WHERE must select the +same key multiset as the same predicate evaluated as a `groupArrayIf` aggregate argument over a +full scan, and (b) `count(P) + count(NOT P) + count(P IS NULL) = count(*)`. +**Authoring lesson: `materialize()` plus `use_skip_indexes=0 / allow_statistics_optimize=0 / +convert_query_to_cnf=0 / optimize_move_to_prewhere=0 / force_primary_key=0` does NOT defeat +partition-level or primary-key-level pruning** — the first draft used that as its reference arm and +was silently comparing two equally-pruned answers. The sound reference is a predicate that never +reaches a WHERE clause at all: `groupArrayIf(k, ifNull((P), 0))` over the whole table. Copy that +pattern for any future pruning oracle. + +**3 — `DistributedPlanEquivalence` oracle** (`--distributed-plan-equivalence-oracle`). One +generated read must return the same multiset under plain local execution, +`make_distributed_plan = 1`, `serialize_query_plan = 1`, a `cluster('default', ...)` read with +`parallel_replicas_local_plan` on and off, and `enable_parallel_replicas = 1` + +`max_parallel_replicas = 3` + `parallel_replicas_for_non_replicated_merge_tree = 1` over both the +local and a `Distributed(...)` relation. Five query shapes including a three-way comma join whose +middle relation is a VIEW (the #111727 shape). The single-node `default` cluster exists on head +(1 shard, 1 replica, localhost), so all six profiles genuinely execute. + +**4 — views and comma joins reach multi-relation FROM lists.** Three changes: +`--persistent-view-emission` (default on) adds a `VIEW` DDL action to the provider that creates up +to 3 plain `v` views per database, so views survive in the schema snapshot instead of existing +only inside `ViewEquivalence`'s single iteration; `--comma-join-emission` (default on) lets the +join generator emit **genuine ON-less CROSS joins** — previously every CROSS was handed an ON +clause and silently degraded into an INNER join, so the fork could never produce `FROM t0, v0, t1` +— and raises the chain to up to four relations; and `ClickHouseJoinReorderOracle` builds a VIEW over +one of its private tables 40% of the time. Because views are now visible to every oracle, +**write paths must filter them**: `ClickHouseAlterGenerator` and `ClickHouseMutationGenerator` moved +to `getDatabaseTablesWithoutViews()`, and `ClickHouseCERTOracle` / `ClickHouseRowPolicyOracle` grew +`!isView()` filters. Any new oracle that INSERTs, ALTERs or OPTIMIZEs a schema-picked table must do +the same. + +**5 — join-order enumerator sweep.** `ClickHouseJoinReorderOracle.checkEnumerationAlgorithms` runs +the same N-way join under `query_plan_optimize_join_order_algorithm` ∈ {greedy, dpsize, dpsub, +dphyp, dphyp+greedy, dpsub+greedy}, plus `query_plan_enable_optimizations = 0`, +`query_plan_join_shard_by_pk_ranges = 1` and `query_plan_optimize_join_order_max_searched_plans=1`. +**The setting is `query_plan_optimize_join_order_algorithm`, not `query_plan_join_reorder_algorithm` +as the plan guessed.** `dpsize` and `dphyp` only support inner joins and raise +`Code: 717 (EXPERIMENTAL_FEATURE_ERROR) "Failed to find a valid join order, try adding 'greedy' +algorithm as fallback"` on outer/semi/anti chains; that is a legitimate unsupported-shape error, not +a finding, and is tolerated in a dedicated `algorithmErrors` set (924 reproducers in the first +validation run were all this one message). + +**6 — `CodecRoundtrip` oracle** (`--codec-roundtrip-oracle`). A table with random per-type +`CODEC(...)` declarations and a `CODEC(NONE)` mirror holding the same rows (including NaN, ±inf, +-0.0, denormals) must answer identically, still after `OPTIMIZE ... FINAL`, and still after an +`ALTER TABLE ... MODIFY COLUMN ... CODEC` mutation. The coded table sometimes carries +`allow_experimental_adaptive_codec_selection = 1` (PR #111834). Lossy codecs (`SZ3`, `ZXC`) are +excluded from the equality arm by allowlist and only have row count and NULL mask asserted; if the +lossy DDL is rejected the oracle retries with a lossless float codec instead of dropping the +iteration. `ALP` was also added to the general schema's float codec pool in `ClickHouseColumnBuilder`. + +### Known-open bugs the 2026-08-15 batch deliberately fires on + +Triage a run by these first; they are expected noise on a current head, not regressions. + +- **UNFILED — `NOT (NOT key)` in value position prunes valid parts.** Found by item 1's emission, + confirmed on head 26.8.1.1470. The projection says the predicate is true for every row, the WHERE + form returns a subset, and `EXPLAIN indexes = 1` prints `Condition: (c1 in (-Inf, 3])`. Root cause + is the `name == "not"` branch of `cloneDAGWithInversionPushDown` in + `src/Storages/MergeTree/KeyCondition.cpp` treating `not` as purely logical and ignoring + `boolean_context`, so two flips cancel and `NOT NOT c1` degrades to bare `c1`. **No setting + disables it** — `materialize()`, `use_skip_indexes=0`, `allow_statistics_optimize=0`, + `query_plan_enable_optimizations=0` and `optimize_move_to_prewhere=0` all still return the wrong + rows — so `KeyCondition` cannot catch it. **NoREC and TLPWhere do** (`countIf(P)` = 2 vs + `count() WHERE P` = 1). Wrong since at least 24.8. Triage by `NOT (NOT` in the failing query. + ```sql + CREATE TABLE t (c1 Int32) ENGINE = MergeTree ORDER BY c1; + INSERT INTO t VALUES (0); INSERT INTO t VALUES (100); + SELECT c1, (NOT (NOT c1)) <= 3.14 FROM t; -- predicate is 1 for BOTH rows + SELECT count() FROM t WHERE (NOT (NOT c1)) <= 3.14; -- 1, must be 2 + SELECT countIf((NOT (NOT c1)) <= 3.14) FROM t; -- 2, correct + ``` +- **[#113417](https://github.com/ClickHouse/ClickHouse/issues/113417) / + [#112036](https://github.com/ClickHouse/ClickHouse/issues/112036) — NaN rows dropped by float part + pruning under a negated comparison.** The `FloatPruning` oracle is a deliberate detector for this + family and fires on a current head **at default settings** — a 6-minute standalone run produced + 326 worker deaths over 175 queries. It is therefore **deliberately absent from + `run-sqlancer.sh`'s `ALL_ORACLES`**; run it standalone with `--oracles FloatPruning` and add it + back once these issues close. (A constantly-firing oracle kills a worker and orphans a database + per iteration, which is what stalled the 2026-06-14 20h run.) Confirmed on 26.8.1.1470: + ```sql + CREATE TABLE fp (k Int64, f32 Float32, f64 Float64) ENGINE = MergeTree ORDER BY (f64, k) + PARTITION BY f32 SETTINGS index_granularity = 8, allow_floating_point_partition_key = 1; + INSERT INTO fp VALUES (0, nan, nan), (1, 1.5, 1.5), (2, -inf, inf), (3, 0, -0.0); + INSERT INTO fp VALUES (4, nan, nan), (5, nan, nan); + INSERT INTO fp VALUES (6, 100, -3.14), (7, -1.5, 0.0000001); + SELECT k FROM fp WHERE NOT (f64 < 1.5); -- {1,2}; must be {0,1,2,4,5} (the NaN rows are dropped) + SELECT count() FROM fp WHERE (NOT (f64 < 1.5)); -- 2 + SELECT count() FROM fp WHERE NOT (NOT (f64 < 1.5)); -- 3 + SELECT count() FROM fp WHERE (NOT (f64 < 1.5)) IS NULL; -- 0, and 2+3+0 != 8 + ``` +- **[#114113](https://github.com/ClickHouse/ClickHouse/issues/114113)** — `LOGICAL_ERROR "Left and + right columns have same names"` out of `chooseJoinOrder` for a three-way comma join whose middle + relation is a VIEW; aborts asan/ubsan servers. Item 4 makes this shape reachable, so the message + is **pinned** via `ClickHouseErrors.getKnownOpenJoinOrderBugs()` (consumed by + `addExpectedExpressionErrors` and by `ClickHouseJoinReorderOracle`). It did **not** reproduce on + the release build 26.8.1.1470 with the plan's minimal `SELECT * FROM t0, v0, t1 WHERE `. + **Remove the pin when the issue closes.** + ## Filed ClickHouse bugs — reproducer → issue (open only) Bugs SQLancer found here that are filed and still OPEN upstream. Minimal repros so a future run can diff --git a/docs/plans/2026-08-15-001-feat-clickhouse-4month-coverage-gap-plan.md b/docs/plans/2026-08-15-001-feat-clickhouse-4month-coverage-gap-plan.md index ee686a406..f954dd705 100644 --- a/docs/plans/2026-08-15-001-feat-clickhouse-4month-coverage-gap-plan.md +++ b/docs/plans/2026-08-15-001-feat-clickhouse-4month-coverage-gap-plan.md @@ -1,7 +1,7 @@ --- title: "feat: ClickHouse coverage gap audit, 2026-04-15 to 2026-08-15 (25 prioritized items)" type: feat -status: proposed +status: p0-implemented date: 2026-08-15 related: - docs/plans/2026-06-13-001-feat-clickhouse-coverage-backlog-30-ideas-plan.md @@ -152,18 +152,44 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and | 24 | `AT TIME ZONE`, `AT LOCAL`, `LOCALTIME` | Gen | P2 | S | wrong result | | 25 | Continuous queries, what-if indexes, QueryRunner | Gen+Oracle | P2 | L | crash | +## Implementation status + +**P0 (items 0a, 0b, 1-6) is implemented and validated on dev-vm head 26.8.1.1470 (2026-08-15).** +P1 and P2 remain open. Deviations from the plan as written, all discovered during validation: + +- **Item 1's oracle is NoREC/TLPWhere, not KeyCondition.** No setting or `materialize()` wrapper + defeats the `NOT (NOT key)` pruning, so the KeyCondition oracle's no-prune arm returns the same + wrong rows as the baseline. `countIf(P)` versus `count() WHERE P` does catch it. The bug is + confirmed on head and still unfiled. +- **Item 2's reference arm had to change for the same reason.** The plan specified `materialize()` + plus a pruning-off settings profile; that does not disable partition-level or primary-key-level + pruning, so the shipped oracle compares against `groupArrayIf(k, ifNull((P), 0))` over a full scan + instead, which no optimizer can prune. +- **Item 5's setting is `query_plan_optimize_join_order_algorithm`** (values greedy / dpsize / + dpsub / dphyp, comma-separated fallback lists allowed), not `query_plan_join_reorder_algorithm`. + `dpsize` and `dphyp` reject non-inner joins with Code 717 EXPERIMENTAL_FEATURE_ERROR, which is + tolerated per-arm. +- **Item 4 needed a persistent-view DDL action**, not just a join-picker change: views already were + visible to the join picker, but `ViewEquivalence` dropped its view inside the same iteration, so + no schema snapshot ever contained one. It also needed a real ON-less CROSS join, because every + CROSS was previously handed an ON clause and degraded into an INNER join. +- **#114113 did not reproduce** on release build 26.8.1.1470 with the plan's minimal repro. The + error message is pinned anyway, per the plan. + +See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for operational detail. + ## P0, prerequisite fixes > These are not coverage items. They are the two false positives found in the 2026-08 nightly triage. Both cost triage time on every run, and both are checklist violations, so they land before new surface is added. -- [ ] **0a. Assert the `LIMIT BY` cap server-side** `[Fix]` `[P0]` `[S]` +- [x] **0a. Assert the `LIMIT BY` cap server-side** `[Fix]` `[P0]` `[S]` - **Problem:** `ClickHouseLimitRankingOracle.checkLimitByCap` reads the key column through `ComparatorHelper.getResultSetFirstColumnAsString`, which pipes every value through `trimTrailingDotZeros`. That helper rewrites `'0.0'` into `'0'`. When the key column is a String holding both `'0.0'` (from the value generator) and `'0'` (from the `numbers(N)` filler), the client sees one key twice and reports a cap violation that does not exist. Confirmed on the 08-04 and 08-07 reproducers: replay against head shows `uniqExact(c0) = count() = 10000` and `LIMIT 1 BY` returning exactly 10000 rows. - **Fix:** compute the violation in ClickHouse instead of in Java: `SELECT max(cnt) FROM (SELECT k, count() AS cnt FROM () GROUP BY k)` and assert the result is at most `n`. This is also strictly cheaper, since it does not ship 10000 rows to the client. - **Files:** `src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java`. - **Verification:** replay both saved reproducers, expect no assertion; one deliberately broken assertion (cap of 0) must still fire. - **Follow-up worth considering separately:** `trimTrailingDotZeros` in `src/sqlancer/ComparatorHelper.java` is a lossy normalisation applied to *every* oracle's result values. It exists to hide float text differences, but it silently merges distinct String values. Scoping it to columns whose type is float, or dropping it in favour of the ULP-tolerant comparison mode that already exists in the same file, would remove a whole class of latent false positives. Checklist rule C8. -- [ ] **0b. Reject degenerate dedupe ORDER BY keys** `[Fix]` `[P0]` `[S]` +- [x] **0b. Reject degenerate dedupe ORDER BY keys** `[Fix]` `[P0]` `[S]` - **Problem:** the engine pool picked `ReplacingMergeTree()` for `t0 (c0 Bool, c1 DateTime, c2 String) ORDER BY c0`. The gate allows ReplacingMergeTree when a viable ver column exists, but the emitted DDL carries no ver argument, and `isValidOrderByForDedupe` accepts `Bool` because it is a bare key column. With only two distinct keys, visible cardinality drops from 7 rows to 2 the moment a background merge runs, which is what produced the 08-07 TLPWhere `91 and 26` mismatch. Replay confirms the table sits at 2 rows. - **Fix:** require the dedupe ORDER BY key to have a non-degenerate domain (reject `Bool`, reject an `Enum` with fewer than some threshold of values, reject any column the generator knows it fills from a tiny value pool), or always emit the ver argument when ReplacingMergeTree is chosen. Both are cheap; doing both is better. - **Files:** `src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java` (`pickEngine`, `isValidOrderByForDedupe`, `isBareKeyColumn`). @@ -173,7 +199,7 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and ### Item 1. Boolean-position and three-valued predicate forms -- [ ] **1. Emit boolean-position wrappers and SQL truth-value predicates in `generatePredicate()`** `[Gen]` `[P0]` `[S]` +- [x] **1. Emit boolean-position wrappers and SQL truth-value predicates in `generatePredicate()`** `[Gen]` `[P0]` `[S]` - **Goal:** feed the existing, already-sound `ClickHouseKeyConditionOracle` the predicate shapes for which ClickHouse performs index and statistics analysis, and which its negation-pushdown code handles incorrectly. This is the single highest-value change in the plan because the oracle already exists, the bug class is already proven, and the change is generator-only. - **ClickHouse surface:** - SQL truth-value predicates `IS TRUE`, `IS FALSE`, `IS UNKNOWN` and their `IS NOT` variants, added by [PR #99997](https://github.com/ClickHouse/ClickHouse/pull/99997) (closes [#99597](https://github.com/ClickHouse/ClickHouse/issues/99597)). @@ -193,7 +219,7 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and ### Item 2. NaN-aware negated-comparison pruning oracle -- [ ] **2. Float and NaN pruning-soundness oracle** `[Oracle]` `[P0]` `[M]` +- [x] **2. Float and NaN pruning-soundness oracle** `[Oracle]` `[P0]` `[M]` - **Goal:** assert that part, granule, partition and statistics pruning never removes a row that the predicate accepts, on float columns containing NaN and negative zero, under negated and CNF-rewritten comparisons. This is currently ClickHouse's densest open wrong-result cluster and the fork's most systematic blind spot. - **ClickHouse surface:** `convert_query_to_cnf`, `optimize_move_to_prewhere`, `use_skip_indexes`, `use_skip_indexes_on_data_read`, `allow_statistics_optimize` and the auto-statistics defaults, minmax and bloom_filter skip indexes, `PARTITION BY` over a float expression, and primary keys over float expressions. Known open bugs in exactly this shape: [#113417](https://github.com/ClickHouse/ClickHouse/issues/113417) statistics-based part pruning drops NaN rows for `NOT (f < c)`; [#112036](https://github.com/ClickHouse/ClickHouse/issues/112036) `convert_query_to_cnf = 1` rewrites `NOT (x < c)` to `x >= c` and silently drops NaN rows; the still-unmerged fix [#107074](https://github.com/ClickHouse/ClickHouse/pull/107074) for minmax skip index and partition pruning skipping NaN under negated float ranges; [#106533](https://github.com/ClickHouse/ClickHouse/issues/106533) metamorphic equivalence violation in HAVING due to NaN partition pruning; [#110266](https://github.com/ClickHouse/ClickHouse/issues/110266), closed, minmax over-prunes a NaN granule for `NOT (f > c)`. The fork already found a member of this family once, [#106262](https://github.com/ClickHouse/ClickHouse/issues/106262), through `TLPSetOp` and by accident. - **Invariant:** for one fixture and one predicate `P`, the row set of `SELECT FROM t WHERE P` must equal the row set of the same query with all pruning disabled. "All pruning disabled" means the `materialize()` wrapper of the existing KeyCondition oracle plus `SETTINGS use_skip_indexes = 0, use_skip_indexes_on_data_read = 0, allow_statistics_optimize = 0, use_query_condition_cache = 0, optimize_move_to_prewhere = 0, convert_query_to_cnf = 0`. Compare as multisets of the key column. Additionally assert the union invariant `count(P) + count(NOT P) + count(P IS NULL) = count(*)` under both settings profiles, which is the shape that catches the CNF rewrite specifically. @@ -207,7 +233,7 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and ### Item 3. Parallel-replicas and distributed-plan equivalence -- [ ] **3. Distributed-plan and parallel-replicas equivalence oracle** `[Oracle]` `[P0]` `[M]` +- [x] **3. Distributed-plan and parallel-replicas equivalence oracle** `[Oracle]` `[P0]` `[M]` - **Goal:** assert that a query answered through the new plan-based distributed and parallel-replica execution paths returns exactly what the plain local path returns. This subsystem was rewritten across five large PRs in this window and already has four open wrong-result issues, and the fork has zero coverage. - **ClickHouse surface:** plan-based parallel replicas parts 1 to 3, [PR #108504](https://github.com/ClickHouse/ClickHouse/pull/108504) aggregation, [PR #111063](https://github.com/ClickHouse/ClickHouse/pull/111063), [PR #112268](https://github.com/ClickHouse/ClickHouse/pull/112268) JOINs; multi-stage distributed queries [PR #106020](https://github.com/ClickHouse/ClickHouse/pull/106020); distributed execution of `CreatingSets` steps [PR #113826](https://github.com/ClickHouse/ClickHouse/pull/113826); `FINAL` reads in distributed plans [PR #108148](https://github.com/ClickHouse/ClickHouse/pull/108148); automatic setting adjustment when `make_distributed_plan` is on [PR #112463](https://github.com/ClickHouse/ClickHouse/pull/112463); per-replica ports for distributed plan workers [PR #107885](https://github.com/ClickHouse/ClickHouse/pull/107885); reimplemented reading in order for parallel replicas [PR #101434](https://github.com/ClickHouse/ClickHouse/pull/101434); `parallel_replicas_prefer_local_replica` [PR #100139](https://github.com/ClickHouse/ClickHouse/pull/100139); pushing a whole outer query to shards for trivial views [PR #101791](https://github.com/ClickHouse/ClickHouse/pull/101791); pushing ORDER BY into simple views for distributed optimization [PR #94102](https://github.com/ClickHouse/ClickHouse/pull/94102). - **Known open wrong results this would target:** [#111727](https://github.com/ClickHouse/ClickHouse/issues/111727) parallel replicas silently multiply results by the replica count when a three-or-more-table JOIN contains a VIEW; [#111654](https://github.com/ClickHouse/ClickHouse/issues/111654) custom-key parallel replicas silently drop a WHERE with an EXISTS operand and return one replica's unfiltered slice; [#111363](https://github.com/ClickHouse/ClickHouse/issues/111363) query condition cache poisoned by a parallel-replicas read of Merge over a VIEW, so later plain queries silently return wrong results; [#113622](https://github.com/ClickHouse/ClickHouse/issues/113622) a parameterized view inside an offloaded JOIN is shipped unqualified; [#113246](https://github.com/ClickHouse/ClickHouse/issues/113246) `make_distributed_plan` throws TYPE_MISMATCH for `IN (SELECT ...)` with a non-convertible literal; [#112028](https://github.com/ClickHouse/ClickHouse/issues/112028) `serialize_query_plan = 1` fails for `LowCardinality IN (subquery)` through a distributed read; [#111211](https://github.com/ClickHouse/ClickHouse/issues/111211) ORDER BY is not applied globally when reading a Distributed table through a Merge engine. @@ -221,7 +247,7 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and ### Item 4. Multi-table joins containing a VIEW -- [ ] **4. Put views into multi-table joins** `[Gen]` `[P0]` `[S]` +- [x] **4. Put views into multi-table joins** `[Gen]` `[P0]` `[S]` - **Goal:** make the FROM-list generator able to place a VIEW as one relation of a three-or-more-relation join. Four separate bugs in this window need exactly that shape, including the one that aborts our own nightly PP server every single run. - **ClickHouse surface:** the join-order optimizer entry point, `src/Processors/QueryPlan/Optimizations/optimizeJoin.cpp` `chooseJoinOrder`, and `src/Interpreters/JoinExpressionActions.cpp`; `analyzer_inline_views`; `analyzer_compatibility_apply_final_to_all_joined_tables` [PR #111589](https://github.com/ClickHouse/ClickHouse/pull/111589); the multiple-join identifier-qualification compatibility setting [PR #110746](https://github.com/ClickHouse/ClickHouse/pull/110746). - **Known bugs of this exact shape:** [#114113](https://github.com/ClickHouse/ClickHouse/issues/114113) open, `SELECT * FROM t0, v0, t1 WHERE ` raises `LOGICAL_ERROR "Left and right columns have same names"` from `chooseJoinOrder`, which aborts an asan or ubsan server; [#111727](https://github.com/ClickHouse/ClickHouse/issues/111727) open, parallel replicas multiply results when a three-or-more-table JOIN contains a VIEW; [#113245](https://github.com/ClickHouse/ClickHouse/issues/113245) open, `analyzer_inline_views = 1` plus a JOIN with a plain VIEW throws ALIAS_REQUIRED; [#111276](https://github.com/ClickHouse/ClickHouse/issues/111276) open, nested-alias JOIN USING key over Distributed can silently join by a shadowed column. Closed precedent for the same optimizer: [#106426](https://github.com/ClickHouse/ClickHouse/issues/106426), found by this fork. @@ -236,7 +262,7 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and ### Item 5. Join-order algorithm sweep -- [ ] **5. Sweep the join-order enumeration algorithms** `[Oracle]` `[P0]` `[S]` +- [x] **5. Sweep the join-order enumeration algorithms** `[Oracle]` `[P0]` `[S]` - **Goal:** run the same multi-way join under every join-order enumerator and assert identical results. Two new enumerators landed in this window and one already has an open conjunct-dropping bug. - **ClickHouse surface:** the DPhyp join-reordering algorithm for inner joins [PR #98798](https://github.com/ClickHouse/ClickHouse/pull/98798); the DPsub enumeration algorithm [PR #107351](https://github.com/ClickHouse/ClickHouse/pull/107351); merging expressions into the join during reordering [PR #98533](https://github.com/ClickHouse/ClickHouse/pull/98533); `query_plan_optimize_join_order_limit`, `query_plan_join_reorder_algorithm`, and `query_plan_join_shard_by_pk_ranges`. - **Known open bugs:** [#111898](https://github.com/ClickHouse/ClickHouse/issues/111898) DPsub join-order reordering with `query_plan_enable_optimizations = 0` silently drops a non-equi `JOIN ON` conjunct on chained joins, a regression after [PR #109638](https://github.com/ClickHouse/ClickHouse/pull/109638); [#112236](https://github.com/ClickHouse/ClickHouse/issues/112236) `query_plan_merge_filter_into_join_condition` rebuilds the leftover WHERE conjunct with a truncating CAST to UInt8; [#111897](https://github.com/ClickHouse/ClickHouse/issues/111897) query condition cache poisoned by a `query_plan_join_shard_by_pk_ranges` plus `full_sorting_merge` multi-threaded join read. @@ -250,7 +276,7 @@ Priority weighting: (bug class, wrong result above crash) x (subsystem youth and ### Item 6. Codec roundtrip oracle -- [ ] **6. Compression codec roundtrip and merge-stability oracle** `[Oracle+Gen]` `[P0]` `[M]` +- [x] **6. Compression codec roundtrip and merge-stability oracle** `[Oracle+Gen]` `[P0]` `[M]` - **Goal:** assert that data written through any codec reads back byte-identically for lossless codecs, and within a declared tolerance for lossy ones, and that it survives merges, mutations and codec changes. Six codec-related changes landed in this window, including two lossy codecs and an adaptive selector that changes codecs *during* merges, and the fork emits `CODEC(` in exactly one file. - **ClickHouse surface:** the ZXC codec [PR #110620](https://github.com/ClickHouse/ClickHouse/pull/110620); the revived SZ3 codec [PR #108788](https://github.com/ClickHouse/ClickHouse/pull/108788) and its NaN quantizer fix [PR #110762](https://github.com/ClickHouse/ClickHouse/pull/110762); the ALP RD variant and variant selection [PR #99654](https://github.com/ClickHouse/ClickHouse/pull/99654); quantization codecs for vector columns with two-stage retrieval [PR #108565](https://github.com/ClickHouse/ClickHouse/pull/108565); adaptive codec selection on merges and mutations [PR #111834](https://github.com/ClickHouse/ClickHouse/pull/111834); reading MergeTree parts with mixed codecs in one stream [PR #108592](https://github.com/ClickHouse/ClickHouse/pull/108592); packed part storage [PR #108118](https://github.com/ClickHouse/ClickHouse/pull/108118) and packed skip-index storage [PR #105321](https://github.com/ClickHouse/ClickHouse/pull/105321) with its uncompressed-size reporting fix [PR #109272](https://github.com/ClickHouse/ClickHouse/pull/109272). - **Related open PR:** [#114531](https://github.com/ClickHouse/ClickHouse/pull/114531) "Reject a lossy codec on columns backing keys and indexes" shows lossy codecs on key columns are a live hazard, which is precisely the shape a fuzzer will generate by accident. From b999e7d1d3b4f6fdcd8ffbe063e8d7004df8b7b6 Mon Sep 17 00:00:00 2001 From: Nikita Fomichev Date: Sun, 16 Aug 2026 02:28:23 +0200 Subject: [PATCH 4/4] feat(clickhouse): P1 coverage items 7-18 of the 4-month gap audit Implements the whole P1 tier of docs/plans/2026-08-15-001-...-4month-coverage-gap-plan.md: three new oracles, arms on eight existing oracles, and four generator changes. New oracles: PipeEquivalence (pipe-operator syntax, PR #111151), IEJoin (two-inequality ON, PR #109920), TupleFinalAggregation (per-element Tuple aggregation in Summing and Coalescing engines, PR #98039). Generator work: GROUPS window frames plus explicit ROWS/RANGE frames, which the window generator previously never emitted at all; negative LIMIT / LIMIT BY / WITH TIES forms; LIKE-OR and !=-AND predicate chains so optimize_or_like_chain and optimize_and_compare_chain are reachable; uniq_v2 and basic statistics everywhere plus an ADD STATISTICS form and auto_statistics_types table settings; descending and mixed-direction sorting keys; sparse serialization that actually engages (low ratio_of_defaults_for_sparse_serialization plus default-biased inserts on plain MergeTree tables); the icu tokenizer. Oracle work: a GROUPS peer-group ground truth and the two degenerate GROUPS identities; negative-limit reversal identities; an indexHint containment arm; query-condition-cache ORDER BY LIMIT coverage, the two open poisoning shapes as triggers, and a re-verify-after-DROP step that separates a cache bug from a merge artifact; hasPhrase with a token-position ground truth, a trivial-count-from-text-index arm and text index parameters as table settings; parallel_full_sorting_merge in the join-algorithm sweep; multi-key GROUP BY and mixed-direction ORDER BY in the read-in-order sweep. Five plan assumptions were wrong and are corrected in the code and both documents: indexHint is not result-neutral (it restricts the read to the granules index analysis selects, so the invariant is containment, and it must not be emitted into the general fleet where TLP branches would read different granule sets); ASC-to-DESC is not an order reversal because ClickHouse sorts NULLs last in both directions; each pipe stage is wrapped in a subquery, so qualified names and MATERIALIZED columns do not survive it; ie_join is the only algorithm that can answer a two-inequality ON, so the reference arm is a CROSS JOIN; null_count statistics do not exist on head. Validated on dev-vm head 26.8.1.1473: 30 minutes over all 97 oracles except TextIndexDirectRead, 162,814 queries, 0 reproducers, 0 threads shut down, with every new arm confirmed present in system.query_log. Two wrong results found on the way, both unfiled and documented in .claude/CLAUDE.md: optimize_aggregation_in_order collapses every GROUP BY group over a DESC sorting key, and an integer constant inside indexHint is narrowed to UInt8 so any multiple of 256 prunes every granule. Also fixes three test expectations left stale by the P0 batch's comma-join rendering. --- .claude/CLAUDE.md | 98 ++++++++ .claude/run-sqlancer.sh | 2 +- ...eat-clickhouse-4month-coverage-gap-plan.md | 87 +++++-- .../clickhouse/ClickHouseOptions.java | 33 +++ .../clickhouse/ClickHouseOracleFactory.java | 24 ++ .../clickhouse/ClickHouseToStringVisitor.java | 3 + .../ast/ClickHouseAlterStatistics.java | 2 +- .../ast/ClickHouseWindowFunction.java | 11 + .../gen/ClickHouseColumnBuilder.java | 7 +- .../gen/ClickHouseExpressionGenerator.java | 108 ++++++++- .../gen/ClickHouseInsertGenerator.java | 65 +++++ .../gen/ClickHouseStatisticsGenerator.java | 22 +- .../gen/ClickHouseTableGenerator.java | 75 +++++- ...ClickHouseTupleFinalAggregationOracle.java | 224 ++++++++++++++++++ .../oracle/join/ClickHouseIEJoinOracle.java | 175 ++++++++++++++ .../join/ClickHouseJoinAlgorithmOracle.java | 3 + .../keycond/ClickHouseKeyConditionOracle.java | 62 +++++ .../limit/ClickHouseLimitRankingOracle.java | 99 +++++++- .../pipe/ClickHousePipeEquivalenceOracle.java | 189 +++++++++++++++ .../ClickHouseQueryConditionCacheOracle.java | 76 +++++- .../ClickHouseReadInOrderToggleOracle.java | 25 +- .../ClickHouseSettingFlipOracle.java | 20 +- .../stats/ClickHouseStatsToggleOracle.java | 25 +- .../ClickHouseTextIndexDirectReadOracle.java | 5 + .../ClickHouseTextIndexLifecycleOracle.java | 110 ++++++++- ...lickHouseWindowFrameGroundTruthOracle.java | 205 ++++++++++++++++ .../ast/ClickHouseSelectArrayJoinTest.java | 4 +- .../ast/ClickHouseToStringVisitorTest.java | 4 +- .../ClickHouseStatsToggleOracleTest.java | 23 +- 29 files changed, 1713 insertions(+), 73 deletions(-) create mode 100644 src/sqlancer/clickhouse/oracle/final_/ClickHouseTupleFinalAggregationOracle.java create mode 100644 src/sqlancer/clickhouse/oracle/join/ClickHouseIEJoinOracle.java create mode 100644 src/sqlancer/clickhouse/oracle/pipe/ClickHousePipeEquivalenceOracle.java diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5f347936b..9055bb263 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -202,6 +202,104 @@ excluded from the equality arm by allowlist and only have row count and NULL mas lossy DDL is rejected the oracle retries with a lossless float codec instead of dropping the iteration. `ALP` was also added to the general schema's float codec pool in `ClickHouseColumnBuilder`. +## P1 coverage batch, 2026-08-16 (same plan, items 7-18) + +Three new oracles — `PipeEquivalence`, `IEJoin`, `TupleFinalAggregation` — plus arms on eight existing +oracles and four generators. All wired into `run-sqlancer.sh`'s `ALL_ORACLES`. + +Validated on dev-vm head **26.8.1.1473**: a 30-minute full-fleet run over all 97 oracles except +`TextIndexDirectRead` finished **162,814 queries, 0 reproducers, 0 threads shut down**. Every new arm was +confirmed to actually execute, by counting it in `system.query_log` for that run: 7322 GROUPS frames, 8982 +trivial-count-from-text-index reads, 4005 `hasPhrase`, 2092 `ie_join` joins, 1046 tuple-aggregation reads, +922 pipe queries, 685 negative LIMITs, 623 `uniq_v2` statistics statements, 562 chain-rewrite flips, 414 +`indexHint` reads, 246 `icu` tokenizer statements, 90 `parallel_full_sorting_merge` joins, and 6092 columns +sitting in `Sparse` serialization. A preceding 15-minute run of only the new and changed oracles did 80,962 +queries and produced exactly two reproducers, both fixed here: a `DROP STATISTICS` versus in-flight-mutation +DDL race (now tolerated) and the `indexHint` constant-truncation bug below (the arm no longer generates that +shape). New flags (all default-on +except the last): `--groups-window-frame-emission`, `--negative-limit-emission`, +`--comparison-chain-emission`, `--index-hint-emission`, `--sparse-column-emission`, +`--mixed-direction-sorting-key`, `--text-index-second-wave`, `--pipe-equivalence-oracle`, +`--ie-join-oracle`, `--tuple-final-aggregation-oracle`, `--summing-subset-projection-arm` (**off**). + +**Probe head before trusting a plan entry.** Five of the twelve items needed a different shape than the plan +assumed, and every one of those was caught by a 5-minute `clickhouse-client` probe against a fresh head +rather than by reading a PR description: + +- **`indexHint` is NOT result-neutral.** It does not evaluate its argument as a filter, but it *does* + restrict the read to the granules index analysis selects, so rows outside them are legitimately dropped + (`WHERE indexHint(c0) AND (exp(c0) AND 2147483648)` = 5 rows, without the hint = 6). The oracle therefore + asserts containment, `rows(P AND Q) ⊆ rows(indexHint(P) AND Q) ⊆ rows(Q)`; the lower bound is the real + pruning-soundness assertion. **Never emit `indexHint` from `generatePredicate`** — granule-level semantics + inside a TLP partition make the three branches read different granule sets, so their union is no longer + the whole table. +- **ASC→DESC is not an order reversal.** ClickHouse sorts NULLs last in *both* directions. Any oracle + building a "reverse total order" arm must write `ASC NULLS LAST` versus `DESC NULLS FIRST` explicitly, or + it reports a false positive the moment a nullable column holds NULL (cost two reproducers in the first + P1 validation run). +- **Pipe operators wrap every stage in a subquery.** `FROM t |> WHERE p` analyses as + `SELECT * FROM (SELECT * FROM t) WHERE p`, so (a) a table-qualified column reference stops resolving after + stage 1 and (b) MATERIALIZED and ALIAS columns vanish, because `SELECT *` does not carry them. The + `PipeEquivalence` oracle is single-relation and uses unqualified, star-visible columns only. Ignoring + either rule produced 1184 Code-47 reproducers in one 12-minute run. +- **IEJoin has nothing to sweep against.** The value is `ie_join` (not `iejoin`), and every other algorithm + rejects a two-inequality ON with `INVALID_JOIN_ON_EXPRESSION`, so the reference arm is the equivalent + `CROSS JOIN ... WHERE`. `EXPLAIN` prints an `IEJoin` step, which is how the arm is confirmed non-decorative. +- **Per-element Tuple aggregation is gated** behind the `allow_tuple_element_aggregation` MergeTree setting + (default 0) and covers SummingMergeTree and CoalescingMergeTree only; a plain `Tuple` is not an aggregate + state, so AggregatingMergeTree keeps the first row and is excluded from the oracle. +- **`null_count` statistics do not exist**; head accepts `basic`, `countmin`, `minmax`, `tdigest`, `uniq`, + `uniq_v2`. `auto_statistics_types` and `materialize_statistics_on_merge` are *MergeTree* settings, while + `use_statistics_for_part_pruning` and `materialize_statistics_on_insert` are *query* settings. +- **The Japanese text tokenizer needs a server dictionary** (``; `NO_ELEMENTS_IN_CONFIG` + on the first INSERT) and there is no posting-list apply-mode setting on head, so those two item-13 + sub-items are unreachable. `icu` needs its locale as a mandatory function argument (`icu('en')`), and it + was probed index-vs-scan equal on startsWith / endsWith / multiSearchAny / hasToken / hasAllTokens / + hasAnyTokens / LIKE / ILIKE, so unlike the tokenizers in the #107186 family it is safe for the general + pool. + +**Sparse serialization now actually engages** (item 15): `ratio_of_defaults_for_sparse_serialization` is set +at a low value on about half of generated tables and a random subset of plain-MergeTree columns is filled +with the type default ~94% of the time. Verified via `system.parts_columns.serialization_kind = 'Sparse'`. +Never applied to a dedupe engine's table, so rule C2 is untouched. Side effect worth knowing: a +default-heavy `String` column makes the pre-existing "cannot parse `''` as a number" noise family more +frequent, which is why `ClickHouseStatsToggleOracle` now pulls in `addExpectedExpressionErrors`. + +### Known-open bugs the 2026-08-16 P1 batch fires on + +- **UNFILED — an integer constant inside `indexHint` is narrowed to UInt8 during index analysis.** Any + multiple of 256 therefore reads as false, the key condition becomes unsatisfiable and every granule is + pruned, while the same constant in a plain WHERE is truthy. Same family as + [#112236](https://github.com/ClickHouse/ClickHouse/issues/112236) (leftover WHERE conjunct rebuilt with a + truncating CAST to UInt8). Confirmed on head 26.8.1.1471. The KeyCondition oracle's `indexHint` argument is + deliberately built as a ` ` comparison so the arm does not keep re-finding this: + with the fuzzer's `256` / `65536` / `2147483648` literal pool a free-form hint argument hits it constantly. + ```sql + CREATE TABLE t (c0 Int64) ENGINE = MergeTree ORDER BY c0; + INSERT INTO t VALUES (1),(2),(3); + SELECT count() FROM t WHERE 256 AND c0 > 0; -- 3 + SELECT count() FROM t WHERE indexHint(256) AND c0 > 0; -- 0, must be 3 + SELECT count() FROM t WHERE indexHint(1) AND c0 > 0; -- 3, so it is the value, not indexHint itself + ``` + +- **UNFILED — `optimize_aggregation_in_order` collapses every GROUP BY group over a DESC sorting key.** + Found by item 18's mixed-direction key emission plus the `ReadInOrderToggle` oracle, confirmed on head + 26.8.1.1471. Same family as [#111901](https://github.com/ClickHouse/ClickHouse/issues/111901) (which is + filed for the two-column `(a, b DESC)` case); the single-column form below is a strictly smaller repro and + is not on that issue. Needs one part: with three separate parts it does not fire. Type-independent + (reproduced on Int64, UInt128, Int128, String). Triage by `optimize_aggregation_in_order = 1` plus a + `DESC` sorting key in the reproducer's DDL. + ```sql + CREATE TABLE t (c Int64) ENGINE = MergeTree ORDER BY c DESC; + INSERT INTO t VALUES (1),(2),(3); + SELECT c, count() FROM t GROUP BY c SETTINGS optimize_aggregation_in_order = 1; -- 1 row: (3,3) WRONG + SELECT c, count() FROM t GROUP BY c SETTINGS optimize_aggregation_in_order = 0; -- 3 rows, correct + -- the two-column (a, b DESC) form of #111901 also still reproduces: + CREATE TABLE t2 (a Int64, b Int64) ENGINE = MergeTree ORDER BY (a, b DESC); + INSERT INTO t2 VALUES (1,1),(1,2),(2,1); + SELECT a, b, count() FROM t2 GROUP BY a, b SETTINGS optimize_aggregation_in_order = 1; -- 2 rows WRONG + ``` + ### Known-open bugs the 2026-08-15 batch deliberately fires on Triage a run by these first; they are expected noise on a current head, not regressions. diff --git a/.claude/run-sqlancer.sh b/.claude/run-sqlancer.sh index 5a8415586..e1b2979ea 100755 --- a/.claude/run-sqlancer.sh +++ b/.claude/run-sqlancer.sh @@ -42,7 +42,7 @@ EXTRA_CH_ARGS="" # 26.x coverage oracles (TextIndexLike..StatsToggle) appended 2026-06-10 after their convergence # run: 3h x 41 oracles x 1.09M queries with --eet-26x-modes/--variant-where-emission on produced # 0 false positives and 1 genuine CH wrong-result (JoinReorder, ANTI/SEMI/INNER chain). -ALL_ORACLES="TLPWhere,TLPDistinct,TLPGroupBy,TLPAggregate,TLPHaving,NoREC,PQS,CERT,CODDTest,SEMR,SEMRMulti,EET,SetOpTLP,CombinatorTLP,QccCache,SortedUnionLimitBy,SchemaRoundtrip,JoinAlgorithm,Cast,Parallelism,PartitionMirror,KeyCondition,TableFunctionIN,ViewEquivalence,AggregateStateRoundtrip,MaterializedViewConsistency,FinalMerge,ProjectionToggle,PatchPartConsistency,DictGetVsJoin,WindowEquivalence,DynamicSubcolumn,SubqueryMaterialize,MutationAnalyzer,TextIndexLike,TopK,JoinReorder,NaturalJoin,JsonSkipIndex,MaterializedCte,StatsToggle,ExtendedDatetime,JoinUseNulls,QueryCache,TextIndexDirectRead,TextIndexContainer,TextIndexLifecycle,PrewhereEquivalence,ReadInOrderToggle,CountOptimization,LazyMaterializationToggle,ReplacingDedup,QuantileConsistency,UniqExactness,ArgExtremum,MaterializedColumn,GroupingDecomposition,LimitRanking,WindowFrame,SemiJoinRewrite,ColumnTransformer,EngineEquivalence,CoalescingFinal,JoinGetSet,RemoteLocalEquivalence,MapTupleContainer,GeoMetamorphic,VariantSubcolumn,AggregateStateExpansion,SequenceFunnel,PartitionLifecycle,AlterModifyConsistency,TtlDeterminism,InsertDedup,TokenBf,VectorIndexRecall,SampleClause,DistributedTable,AsofJoin,CubeGroupingSets,PasteJoin,CorrelatedSubquery,BitFunction,ArrayFunction,StringFunction,AggregateFunctionColumn,TimezoneDatetime,ArrayJoinUnfold,WindowFrameGroundTruth,JoinUsing,WithFill,SettingFlip,ConcurrentMutation,LowCardinalityEquivalence,DistributedPlanEquivalence,CodecRoundtrip" +ALL_ORACLES="TLPWhere,TLPDistinct,TLPGroupBy,TLPAggregate,TLPHaving,NoREC,PQS,CERT,CODDTest,SEMR,SEMRMulti,EET,SetOpTLP,CombinatorTLP,QccCache,SortedUnionLimitBy,SchemaRoundtrip,JoinAlgorithm,Cast,Parallelism,PartitionMirror,KeyCondition,TableFunctionIN,ViewEquivalence,AggregateStateRoundtrip,MaterializedViewConsistency,FinalMerge,ProjectionToggle,PatchPartConsistency,DictGetVsJoin,WindowEquivalence,DynamicSubcolumn,SubqueryMaterialize,MutationAnalyzer,TextIndexLike,TopK,JoinReorder,NaturalJoin,JsonSkipIndex,MaterializedCte,StatsToggle,ExtendedDatetime,JoinUseNulls,QueryCache,TextIndexDirectRead,TextIndexContainer,TextIndexLifecycle,PrewhereEquivalence,ReadInOrderToggle,CountOptimization,LazyMaterializationToggle,ReplacingDedup,QuantileConsistency,UniqExactness,ArgExtremum,MaterializedColumn,GroupingDecomposition,LimitRanking,WindowFrame,SemiJoinRewrite,ColumnTransformer,EngineEquivalence,CoalescingFinal,JoinGetSet,RemoteLocalEquivalence,MapTupleContainer,GeoMetamorphic,VariantSubcolumn,AggregateStateExpansion,SequenceFunnel,PartitionLifecycle,AlterModifyConsistency,TtlDeterminism,InsertDedup,TokenBf,VectorIndexRecall,SampleClause,DistributedTable,AsofJoin,CubeGroupingSets,PasteJoin,CorrelatedSubquery,BitFunction,ArrayFunction,StringFunction,AggregateFunctionColumn,TimezoneDatetime,ArrayJoinUnfold,WindowFrameGroundTruth,JoinUsing,WithFill,SettingFlip,ConcurrentMutation,LowCardinalityEquivalence,DistributedPlanEquivalence,CodecRoundtrip,PipeEquivalence,IEJoin,TupleFinalAggregation" usage() { cat < WHERE p` analyses as `SELECT * FROM (SELECT * FROM t) WHERE p`), which has two consequences a + general AST renderer cannot paper over: a table-qualified column reference stops resolving after stage 1, + and `SELECT *` does not carry MATERIALIZED or ALIAS columns. The oracle therefore renders both forms from + one structure over a single relation with unqualified, star-visible columns. +- **Item 10: the join-algorithm value is `ie_join`, and there is nothing to sweep it against.** Every other + algorithm rejects a two-inequality ON with `INVALID_JOIN_ON_EXPRESSION` / "Cannot determine join keys", so + the reference arm is the equivalent `CROSS JOIN ... WHERE`, not an algorithm comparison. `EXPLAIN` confirms + the `IEJoin` step. `parallel_full_sorting_merge` was added to `JoinAlgorithm`'s existing sweep separately. +- **Item 11: `null_count` statistics do not exist on head.** The accepted set is `basic`, `countmin`, + `minmax`, `tdigest`, `uniq`, `uniq_v2`; `basic` is what PR #102356's null counting ended up inside. The + item shipped as `uniq_v2` + `basic` in every statistics pool, an `ADD STATISTICS` DDL form, multi-type + `TYPE a, b` declarations, and explicit `auto_statistics_types` / `materialize_statistics_on_merge` table + settings. Note the split: those two are MergeTree settings, while `use_statistics_for_part_pruning` and + `materialize_statistics_on_insert` are query settings. +- **Item 12: the cache arms must compare multisets.** The baseline read has no ORDER BY, so cache-on and + cache-off legitimately return the same rows in a different order; a positional compare reported four + "poisoning" hits that were pure row-order differences. +- **Item 13: two sub-items are unreachable on head.** The Japanese tokenizer needs a server-side + `` dictionary the fuzzer's container does not carry (`NO_ELEMENTS_IN_CONFIG` on the + first INSERT), and no lazy / randomized posting-list apply-mode setting exists in `system.settings` or + `system.merge_tree_settings`. What shipped: the `icu('')` tokenizer (the locale argument is + mandatory, `tokenizer = 'icu'` is rejected), a `hasPhrase` arm with a Java token-position ground truth + behind the `allow_experimental_text_index_phrase_search` table setting, a trivial-count arm over + `query_plan_optimize_count_from_text_index`, and text index parameters supplied as table settings. `icu` + was probed index-vs-scan on `startsWith` / `endsWith` / `multiSearchAny` / `hasToken` / `hasAllTokens` / + `hasAnyTokens` / `LIKE` / `ILIKE` and agreed on all eight, so it is safe for the general tokenizer pool as + well as for the oracles. +- **Item 14: per-element Tuple aggregation is gated and narrower than the PR title suggests.** It needs the + `allow_tuple_element_aggregation` MergeTree setting (default 0) and it applies to SummingMergeTree and + CoalescingMergeTree only -- a plain `Tuple` is not an aggregate state, so AggregatingMergeTree keeps the + first row and is excluded. The #106125 subset-projection detector is a separate default-off flag + (`--summing-subset-projection-arm`): the bug still reproduces on head, so with the arm on the oracle + asserts nearly every iteration. +- **Item 17: `indexHint` is not result-neutral, so the plan's equality invariant is wrong.** `indexHint(P)` + does not evaluate P as a filter, but it does restrict the read to the granules index analysis selects for + P, so rows outside those granules are legitimately dropped: measured 5 rows versus 6 on head for + `WHERE indexHint(c0) AND (exp(c0) AND 2147483648)`. The shipped invariant is containment, + `rows(P AND Q) ⊆ rows(indexHint(P) AND Q) ⊆ rows(Q)`, whose lower bound is exactly the pruning-soundness + assertion the item wanted. For the same reason `indexHint` is **not** emitted into the general fleet's + `generatePredicate`: inside a TLP partition the three branches would read different granule sets and their + union would no longer be the whole table. +- **Item 18 found a real wrong result on head, minimised to three lines.** See the entry in `.claude/CLAUDE.md`. + ## P0, prerequisite fixes > These are not coverage items. They are the two false positives found in the 2026-08 nightly triage. Both cost triage time on every run, and both are checklist violations, so they land before new surface is added. @@ -292,7 +351,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 7. `GROUPS` window frame mode -- [ ] **7. Emit and ground-truth the `GROUPS` window frame mode** `[Gen+Oracle]` `[P1]` `[S]` +- [x] **7. Emit and ground-truth the `GROUPS` window frame mode** `[Gen+Oracle]` `[P1]` `[S]` - **Goal:** cover the third window frame mode. The fork has three window oracles and emits `ROWS` and `RANGE` frames, but `GROUPS` is brand new and completely unexercised. - **ClickHouse surface:** `GROUPS` frame mode for window functions, [PR #108653](https://github.com/ClickHouse/ClickHouse/pull/108653), merged 2026-08-13. Syntax is `GROUPS BETWEEN PRECEDING AND FOLLOWING`, where the offsets count *peer groups* (rows tied on the ORDER BY key) rather than rows. - **Invariant:** `GROUPS` is exactly ground-truthable in Java, which is the strongest oracle shape available. Fetch the fixture, sort by the window ORDER BY key, partition into peer groups, and compute the expected aggregate per row. Assert equality against ClickHouse. Additionally assert the degenerate identities: with a fixture whose ORDER BY key is unique, `GROUPS n PRECEDING` must equal `ROWS n PRECEDING`; with a fixture whose key is constant, every row is one peer group so `GROUPS 0 PRECEDING AND 0 FOLLOWING` must equal the whole-partition aggregate. @@ -305,7 +364,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 8. Negative `LIMIT BY` and `WITH TIES` on negative `LIMIT` -- [ ] **8. Emit negative LIMIT forms** `[Gen+Oracle]` `[P1]` `[S]` +- [x] **8. Emit negative LIMIT forms** `[Gen+Oracle]` `[P1]` `[S]` - **Goal:** cover the negative-offset LIMIT family. ClickHouse both *added* these forms and *rewrote* their execution path inside this window, and the fork's LIMIT oracle only emits non-negative limits. - **ClickHouse surface:** negative `LIMIT BY`, [PR #103222](https://github.com/ClickHouse/ClickHouse/pull/103222); `WITH TIES` for negative `LIMIT`, [PR #100930](https://github.com/ClickHouse/ClickHouse/pull/100930); the performance rewrite of `DISTINCT` in order, sort-merge joins, `LIMIT BY` and negative `LIMIT BY`, [PR #106502](https://github.com/ClickHouse/ClickHouse/pull/106502); removal of redundant `LIMIT BY` key expressions, [PR #106818](https://github.com/ClickHouse/ClickHouse/pull/106818); `DISTINCT` run independently per partition, [PR #108326](https://github.com/ClickHouse/ClickHouse/pull/108326); removal of the legacy `DistinctSortedTransform`, [PR #110170](https://github.com/ClickHouse/ClickHouse/pull/110170). - **Related open bug in the neighbourhood:** [#112029](https://github.com/ClickHouse/ClickHouse/issues/112029), Merge over Distributed plus JOIN plus `LIMIT n WITH TIES` keeps `WITH TIES` in the shard fragment but drops the ORDER BY. @@ -319,7 +378,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 9. Pipe operators -- [ ] **9. Pipe-operator equivalence oracle** `[Gen+Oracle]` `[P1]` `[M]` +- [x] **9. Pipe-operator equivalence oracle** `[Gen+Oracle]` `[P1]` `[M]` - **Goal:** cover an entirely new query syntax. A pipe query and its classic-SQL equivalent must return the same thing, which is a textbook metamorphic oracle and needs no ground truth at all. - **ClickHouse surface:** pipe operators in SQL queries, [PR #111151](https://github.com/ClickHouse/ClickHouse/pull/111151), merged 2026-08-11. Reachable at parse time, so it exercises the parser, the analyzer's query-tree construction, and every rewrite that assumes a classic clause order. - **Invariant:** the generator already builds a `ClickHouseSelect` AST and renders it through `ClickHouseToStringVisitor`. Add a second visitor that renders the same AST in pipe form, then assert the two render forms return identical results in one iteration. This mirrors the `MaterializedColumnVisitor` pattern in `ClickHouseKeyConditionOracle`, which already proves that a second visitor over one AST is a cheap way to build a differential. @@ -332,7 +391,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 10. IEJoin -- [ ] **10. Generate joins whose ON has two inequality comparisons** `[Gen+Oracle]` `[P1]` `[M]` +- [x] **10. Generate joins whose ON has two inequality comparisons** `[Gen+Oracle]` `[P1]` `[M]` - **Goal:** reach the new IEJoin algorithm. It only activates for a specific ON shape that the fork never generates, so the algorithm is currently untested by us. - **ClickHouse surface:** IEJoin support for joins whose ON has two inequality comparisons, [PR #109920](https://github.com/ClickHouse/ClickHouse/pull/109920), merged 2026-08-06. This is the interval-join algorithm, so the trigger shape is `ON a.x < b.x AND a.y > b.y`. - **Invariant:** the same join must return the same rows under IEJoin and under every other applicable `join_algorithm` (`hash`, `parallel_hash`, `grace_hash`, `full_sorting_merge`, and the new `parallel_full_sorting_merge` from [PR #109005](https://github.com/ClickHouse/ClickHouse/pull/109005)). This is exactly what `ClickHouseJoinAlgorithmOracle` already does; the missing piece is a generator that produces the two-inequality ON shape, plus `parallel_full_sorting_merge` in the algorithm list. @@ -345,7 +404,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 11. New statistics types -- [ ] **11. Emit `null_count` and `uniq_v2` statistics and toggle the new defaults** `[Gen+Oracle]` `[P1]` `[S]` +- [x] **11. Emit `null_count` and `uniq_v2` statistics and toggle the new defaults** `[Gen+Oracle]` `[P1]` `[S]` - **Goal:** cover the statistics types added in this window, and the two default changes that mean nearly every fuzzed table now carries statistics whether we asked for them or not. - **ClickHouse surface:** `null_count` statistics, [PR #102356](https://github.com/ClickHouse/ClickHouse/pull/102356), and NullCount statistics support for part pruning, [PR #104214](https://github.com/ClickHouse/ClickHouse/pull/104214); `uniq_v2` statistics backed by `UniqCombined64(12)`, [PR #107863](https://github.com/ClickHouse/ClickHouse/pull/107863); the default `auto_statistics_types` change from `basic, uniq` to `basic, uniq_v2`, [PR #110878](https://github.com/ClickHouse/ClickHouse/pull/110878); materializing column statistics on INSERT for small tables by default, [PR #109454](https://github.com/ClickHouse/ClickHouse/pull/109454); the sparse `checkInHyperrectangle` change, [PR #110153](https://github.com/ClickHouse/ClickHouse/pull/110153); skipping predicate statistics counters when the feature is off, [PR #108190](https://github.com/ClickHouse/ClickHouse/pull/108190). - **Known open bug this targets:** [#113417](https://github.com/ClickHouse/ClickHouse/issues/113417), statistics-based part pruning drops NaN rows, which is also item 2's positive control. The two items are complementary: item 2 supplies the float and NaN data, item 11 supplies the statistics variety. @@ -359,7 +418,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 12. Query condition cache -- [ ] **12. Cover `ORDER BY ... LIMIT n` caching and cross-query cache poisoning** `[Oracle]` `[P1]` `[M]` +- [x] **12. Cover `ORDER BY ... LIMIT n` caching and cross-query cache poisoning** `[Oracle]` `[P1]` `[M]` - **Goal:** cover the query condition cache shape whose default was flipped three times in four months, and the failure mode that a single-query oracle structurally cannot see: one query poisoning the cache so that a *later, different* query returns wrong results. - **ClickHouse surface:** enabling the query condition cache for `ORDER BY ... LIMIT n`, [PR #104478](https://github.com/ClickHouse/ClickHouse/pull/104478); better coverage for `ORDER BY ... LIMIT k`, [PR #110507](https://github.com/ClickHouse/ClickHouse/pull/110507); disabling it by default, [PR #111492](https://github.com/ClickHouse/ClickHouse/pull/111492); re-enabling it by default, [PR #114539](https://github.com/ClickHouse/ClickHouse/pull/114539), merged 2026-08-13; not disabling the cache for materialized lightweight deletes, [PR #112947](https://github.com/ClickHouse/ClickHouse/pull/112947); the cache key derivation, [#112016](https://github.com/ClickHouse/ClickHouse/issues/112016). - **Known open poisoning bugs:** [#111897](https://github.com/ClickHouse/ClickHouse/issues/111897), the cache is poisoned by a `query_plan_join_shard_by_pk_ranges` plus `full_sorting_merge` multi-threaded join read; [#111363](https://github.com/ClickHouse/ClickHouse/issues/111363), the cache is poisoned by a parallel-replicas read of Merge over a VIEW, and later plain queries silently return wrong results. Both are "query A breaks query B", which is a different oracle shape from anything the fork has. @@ -373,7 +432,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 13. Text index second wave -- [ ] **13. Extend the text-index oracles to the second wave of features** `[Gen+Oracle]` `[P1]` `[M]` +- [x] **13. Extend the text-index oracles to the second wave of features** `[Gen+Oracle]` `[P1]` `[M]` - **Goal:** the fork has four text-index oracles built in June, and ClickHouse then shipped another nine text-index changes. Bring the oracles up to the current feature set. - **ClickHouse surface, all merged in this window:** the ICU tokenizer, [PR #109940](https://github.com/ClickHouse/ClickHouse/pull/109940); the Japanese MeCab tokenizer, [PR #111420](https://github.com/ClickHouse/ClickHouse/pull/111420); storing positions for better phrase search, [PR #103172](https://github.com/ClickHouse/ClickHouse/pull/103172), which is what makes `hasPhrase` order-sensitive; the text index postprocessor, [PR #98939](https://github.com/ClickHouse/ClickHouse/pull/98939) and its resubmit [PR #108606](https://github.com/ClickHouse/ClickHouse/pull/108606), plus the filter-only postprocessor fast path, [PR #109049](https://github.com/ClickHouse/ClickHouse/pull/109049); lazy posting-list evaluation mode, [PR #100035](https://github.com/ClickHouse/ClickHouse/pull/100035), and randomized posting-list apply mode, [PR #108814](https://github.com/ClickHouse/ClickHouse/pull/108814); text index parameters via table settings, [PR #100626](https://github.com/ClickHouse/ClickHouse/pull/100626); trivial count optimization for text indexes, [PR #111494](https://github.com/ClickHouse/ClickHouse/pull/111494); caching missing tokens, [PR #112742](https://github.com/ClickHouse/ClickHouse/pull/112742); generic exclusion search for text index analysis, [PR #110530](https://github.com/ClickHouse/ClickHouse/pull/110530); pushing current mark ranges into the text index analyzer, [PR #108114](https://github.com/ClickHouse/ClickHouse/pull/108114); configurable flush limits, [PR #111573](https://github.com/ClickHouse/ClickHouse/pull/111573); `system.stemmers`, [PR #100611](https://github.com/ClickHouse/ClickHouse/pull/100611); `tokenizeQuery` and `highlightQuery`, [PR #101054](https://github.com/ClickHouse/ClickHouse/pull/101054). - **Known open bugs nearby:** [#105848](https://github.com/ClickHouse/ClickHouse/pull/105848) text index for LIKE/ILIKE with ESCAPE; [#107038](https://github.com/ClickHouse/ClickHouse/issues/107038) skip indexes on subcolumns ignored when querying through a view; the still-unmerged fix [#113157](https://github.com/ClickHouse/ClickHouse/pull/113157) for text and token skip indexes over-pruning IPv6 columns. @@ -387,7 +446,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 14. `Tuple` per-element aggregation in summing engines -- [ ] **14. Emit Tuple columns in SummingMergeTree, AggregatingMergeTree and CoalescingMergeTree** `[Gen+Oracle]` `[P1]` `[M]` +- [x] **14. Emit Tuple columns in SummingMergeTree, AggregatingMergeTree and CoalescingMergeTree** `[Gen+Oracle]` `[P1]` `[M]` - **Goal:** cover per-element Tuple aggregation in the dedupe engine family, which is the same family that just produced an open wrong-result bug through a different mechanism. - **ClickHouse surface:** support for per-element aggregation of `Tuple` columns in `SummingMergeTree`, `AggregatingMergeTree` and `CoalescingMergeTree`, [PR #98039](https://github.com/ClickHouse/ClickHouse/pull/98039), merged 2026-06-11. - **Known open bug in the same family, worth using as a positive control:** [#106125](https://github.com/ClickHouse/ClickHouse/issues/106125), query-time `FINAL` on SummingMergeTree applies the zero-row-deletion rule over only the columns the query reads. The minimal repro found during this audit is worth adding to the fork's own regression notes: with `mini (k UInt32, v_nonzero Int32, v_zero UInt8) ENGINE = SummingMergeTree ORDER BY k` and two identical inserts of `(1,100,0),(2,200,0)`, `SELECT k, v_nonzero, v_zero FROM mini FINAL` returns 2 rows while `SELECT count() FROM mini FINAL` returns 0, and both become correct after `OPTIMIZE TABLE ... FINAL`. That is a strictly better repro than the one on the issue and demonstrates rule C7 (never measure a presence bug with `count()`). @@ -401,7 +460,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 15. Sparse columns -- [ ] **15. Emit high-default-ratio columns so sparse serialization engages** `[Gen]` `[P1]` `[S]` +- [x] **15. Emit high-default-ratio columns so sparse serialization engages** `[Gen]` `[P1]` `[S]` - **Goal:** make sparse serialization actually happen in fuzzed tables, so the new sparse-aware pruning and trivial-count paths are exercised by every existing oracle for free. - **ClickHouse surface:** sparsity-aware part and granule pruning plus the trivial-count optimization, [PR #105890](https://github.com/ClickHouse/ClickHouse/pull/105890); `InlinedVector` for the RPN stack in sparse `checkInHyperrectangle`, [PR #110153](https://github.com/ClickHouse/ClickHouse/pull/110153); the controlling table setting is `ratio_of_defaults_for_sparse_serialization`. - **Invariant:** none of its own. This is a pure emission change: set `ratio_of_defaults_for_sparse_serialization` explicitly in `CREATE TABLE` settings, and make the insert generator produce columns that are overwhelmingly default (say 95 percent zeros or empty strings) for a subset of columns. Every existing pruning, count and FINAL oracle then covers sparse serialization at no extra cost, which is the highest leverage available for an S-effort change. @@ -414,7 +473,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 16. Comparison and LIKE chain rewrites -- [ ] **16. Toggle `optimize_or_like_chain` and `optimize_and_compare_chain`** `[Oracle]` `[P1]` `[S]` +- [x] **16. Toggle `optimize_or_like_chain` and `optimize_and_compare_chain`** `[Oracle]` `[P1]` `[S]` - **Goal:** put a targeted differential on two AST rewrites that *prune* predicates, one of which was turned on by default in this window. - **ClickHouse surface:** enabling `optimize_or_like_chain` by default, [PR #94517](https://github.com/ClickHouse/ClickHouse/pull/94517), merged 2026-07-13, which rewrites a chain of `LIKE` disjunctions into `multiMatchAny`; the AND comparison-chain optimizer that detects conflicts and prunes redundancies, [PR #99736](https://github.com/ClickHouse/ClickHouse/pull/99736); the bound on its analysis cost, [PR #108757](https://github.com/ClickHouse/ClickHouse/pull/108757); and the older `convert_query_to_cnf`, which item 2 also covers from the float side. - **Known bug precedent:** [#104537](https://github.com/ClickHouse/ClickHouse/issues/104537), `tryOptimizeAndEqualsNotEqualsChain` loses type information when converting a `notEquals` chain to `NOT IN`, causing wrong results. Same code path, already broken once. @@ -428,7 +487,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 17. `indexHint` -- [ ] **17. Emit `indexHint` and assert it does not change results** `[Gen+Oracle]` `[P1]` `[S]` +- [x] **17. Emit `indexHint` and assert it does not change results** `[Gen+Oracle]` `[P1]` `[S]` - **Goal:** cover a function whose entire contract is "affects index analysis, never affects the result set", which makes it the purest possible pruning-soundness assertion, and which has an open wrong-result bug right now. - **ClickHouse surface:** `indexHint(...)`, handled as a logical no-op in `KeyCondition.cpp` (see `isLogicalOperator` and the `indexHint` branch of `cloneDAGWithInversionPushDown`, both of which item 1 also touches). - **Known open bug:** [#112035](https://github.com/ClickHouse/ClickHouse/issues/112035), `indexHint` in a WHERE over the right table of a LEFT JOIN prunes right-side granules and flips matched rows to unmatched, silently. @@ -442,7 +501,7 @@ See the `## P0 coverage batch, 2026-08-15` section of `.claude/CLAUDE.md` for op ### Item 18. Mixed-direction sorting keys -- [ ] **18. Emit mixed-direction ORDER BY keys and sweep aggregation-in-order** `[Gen+Oracle]` `[P1]` `[S]` +- [x] **18. Emit mixed-direction ORDER BY keys and sweep aggregation-in-order** `[Gen+Oracle]` `[P1]` `[S]` - **Goal:** reach the read-in-order and aggregation-in-order code paths for a sorting key that is not uniformly ascending, which is where they currently break. - **ClickHouse surface:** `optimize_read_in_order`, `optimize_aggregation_in_order`, `read_in_order_use_buffering`; read-in-order propagation through `SpillingHashJoin`, [PR #111973](https://github.com/ClickHouse/ClickHouse/pull/111973); avoiding scans for constant sort keys, [PR #113899](https://github.com/ClickHouse/ClickHouse/pull/113899); the unordered stream modifier, [PR #111794](https://github.com/ClickHouse/ClickHouse/pull/111794); `STREAM BOUNDED`, [PR #110653](https://github.com/ClickHouse/ClickHouse/pull/110653); reimplemented reading in order for parallel replicas, [PR #101434](https://github.com/ClickHouse/ClickHouse/pull/101434). - **Known open bug:** [#111901](https://github.com/ClickHouse/ClickHouse/issues/111901), `optimize_aggregation_in_order` over a mixed-direction sorting key `(a, b DESC)` collapses GROUP BY groups, a silent wrong result. Also nearby: [#114407](https://github.com/ClickHouse/ClickHouse/issues/114407), `toUnixTimestamp()` in ORDER BY silently loses primary-key pruning from 26.7, which is a performance regression rather than a wrong result but lives in the same emission gap. diff --git a/src/sqlancer/clickhouse/ClickHouseOptions.java b/src/sqlancer/clickhouse/ClickHouseOptions.java index d14f3d28e..45127a2a6 100644 --- a/src/sqlancer/clickhouse/ClickHouseOptions.java +++ b/src/sqlancer/clickhouse/ClickHouseOptions.java @@ -228,6 +228,39 @@ public class ClickHouseOptions implements DBMSSpecificOptions getTestOracleFactory() { return oracle; diff --git a/src/sqlancer/clickhouse/ClickHouseOracleFactory.java b/src/sqlancer/clickhouse/ClickHouseOracleFactory.java index 0f519aaf5..819f491fd 100644 --- a/src/sqlancer/clickhouse/ClickHouseOracleFactory.java +++ b/src/sqlancer/clickhouse/ClickHouseOracleFactory.java @@ -17,6 +17,9 @@ import sqlancer.clickhouse.oracle.keycond.ClickHouseKeyConditionOracle; import sqlancer.clickhouse.oracle.keycond.ClickHouseFloatPruningOracle; import sqlancer.clickhouse.oracle.codec.ClickHouseCodecRoundtripOracle; +import sqlancer.clickhouse.oracle.join.ClickHouseIEJoinOracle; +import sqlancer.clickhouse.oracle.pipe.ClickHousePipeEquivalenceOracle; +import sqlancer.clickhouse.oracle.final_.ClickHouseTupleFinalAggregationOracle; import sqlancer.clickhouse.oracle.materialize.ClickHouseSubqueryMaterializeOracle; import sqlancer.clickhouse.oracle.parallelism.ClickHouseParallelismOracle; import sqlancer.clickhouse.oracle.cte.ClickHouseMaterializedCteOracle; @@ -781,5 +784,26 @@ public TestOracle create(ClickHouseGlobalState globalStat public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { return new ClickHouseCodecRoundtripOracle(globalState); } + }, + PipeEquivalence { + + @Override + public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { + return new ClickHousePipeEquivalenceOracle(globalState); + } + }, + IEJoin { + + @Override + public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { + return new ClickHouseIEJoinOracle(globalState); + } + }, + TupleFinalAggregation { + + @Override + public TestOracle create(ClickHouseGlobalState globalState) throws SQLException { + return new ClickHouseTupleFinalAggregationOracle(globalState); + } } } diff --git a/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java b/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java index 881159812..c326a1db8 100644 --- a/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java +++ b/src/sqlancer/clickhouse/ClickHouseToStringVisitor.java @@ -402,6 +402,9 @@ public void visit(sqlancer.clickhouse.ast.ClickHouseWindowFunction window) { } visit(window.getOrderBy().get(i)); } + if (window.getFrame() != null) { + sb.append(" ").append(window.getFrame()); + } } sb.append(")"); } diff --git a/src/sqlancer/clickhouse/ast/ClickHouseAlterStatistics.java b/src/sqlancer/clickhouse/ast/ClickHouseAlterStatistics.java index 0d3618967..7b96c3089 100644 --- a/src/sqlancer/clickhouse/ast/ClickHouseAlterStatistics.java +++ b/src/sqlancer/clickhouse/ast/ClickHouseAlterStatistics.java @@ -3,7 +3,7 @@ public class ClickHouseAlterStatistics extends ClickHouseDdlStatement { public enum Kind { - MODIFY_STATISTICS, MATERIALIZE_STATISTICS, DROP_STATISTICS + ADD_STATISTICS, MODIFY_STATISTICS, MATERIALIZE_STATISTICS, DROP_STATISTICS } private final Kind kind; diff --git a/src/sqlancer/clickhouse/ast/ClickHouseWindowFunction.java b/src/sqlancer/clickhouse/ast/ClickHouseWindowFunction.java index e38b16fa9..850928aa3 100644 --- a/src/sqlancer/clickhouse/ast/ClickHouseWindowFunction.java +++ b/src/sqlancer/clickhouse/ast/ClickHouseWindowFunction.java @@ -16,13 +16,24 @@ public enum Kind { private final ClickHouseExpression argument; private final List partitionBy; private final List orderBy; + private final String frame; public ClickHouseWindowFunction(Kind kind, ClickHouseExpression argument, List partitionBy, List orderBy) { + this(kind, argument, partitionBy, orderBy, null); + } + + public ClickHouseWindowFunction(Kind kind, ClickHouseExpression argument, List partitionBy, + List orderBy, String frame) { this.kind = kind; this.argument = argument; this.partitionBy = partitionBy == null ? Collections.emptyList() : List.copyOf(partitionBy); this.orderBy = orderBy == null ? Collections.emptyList() : List.copyOf(orderBy); + this.frame = frame; + } + + public String getFrame() { + return frame; } public Kind getKind() { diff --git a/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java b/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java index 3be62a2d3..caad2665a 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseColumnBuilder.java @@ -27,9 +27,10 @@ private enum Constraints { DEFAULT, MATERIALIZED, CODEC, STATISTICS, ALIAS, EPHEMERAL } - private static final List STATISTICS_KINDS_NUMERIC = List.of("tdigest", "uniq", "countmin", "minmax"); - private static final List STATISTICS_KINDS_STRING = List.of("uniq", "countmin"); - private static final List STATISTICS_KINDS_OTHER = List.of("uniq"); + private static final List STATISTICS_KINDS_NUMERIC = List.of("tdigest", "uniq", "countmin", "minmax", + "uniq_v2", "basic"); + private static final List STATISTICS_KINDS_STRING = List.of("uniq", "countmin", "uniq_v2", "basic"); + private static final List STATISTICS_KINDS_OTHER = List.of("uniq", "uniq_v2"); public String createColumn(String columnName, ClickHouseProvider.ClickHouseGlobalState globalState, List columns) { diff --git a/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java index caabc87b7..42ed5dda1 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseExpressionGenerator.java @@ -572,6 +572,65 @@ public ClickHouseExpression generateLikeEscapePredicate(List LIKE_CHAIN_PATTERNS = List.of("a%", "b%", "%c", "%d%", "e_f%", "%1%", "%0", + "9%"); + + public ClickHouseExpression generateComparisonChain(List columns) { + List stringCols = columns.stream() + .filter(c -> c.getColumn().getType().getType() == ClickHouseDataType.String) + .collect(Collectors.toList()); + List intCols = integerColumns(columns); + boolean likeChain = !stringCols.isEmpty() && (intCols.isEmpty() || Randomly.getBoolean()); + if (likeChain) { + return renderLikeOrChain(Randomly.fromList(stringCols)); + } + if (intCols.isEmpty()) { + return null; + } + return renderAndCompareChain(Randomly.fromList(intCols)); + } + + private ClickHouseExpression renderLikeOrChain(ClickHouseColumnReference col) { + String op = Randomly.getBoolean() ? "LIKE '" : "ILIKE '"; + int links = 3 + (int) Randomly.getNotCachedInteger(0, 4); + ClickHouseExpression chain = null; + for (int i = 0; i < links; i++) { + ClickHouseExpression link = new ClickHouseExpression.ClickHousePostfixText(col, + op + Randomly.fromList(LIKE_CHAIN_PATTERNS) + "'", null); + chain = chain == null ? link + : new ClickHouseBinaryLogicalOperation(chain, link, + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.OR); + } + return chain; + } + + private ClickHouseExpression renderAndCompareChain(ClickHouseColumnReference col) { + int links = 3 + (int) Randomly.getNotCachedInteger(0, 5); + ClickHouseExpression chain = null; + for (int i = 0; i < links; i++) { + ClickHouseExpression link = new ClickHouseBinaryComparisonOperation(col, + ClickHouseCreateConstant.createInt32Constant(Randomly.getNotCachedInteger(-20, 20)), + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.NOT_EQUALS); + chain = chain == null ? link + : new ClickHouseBinaryLogicalOperation(chain, link, + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND); + } + long bound = Randomly.getNotCachedInteger(-20, 20); + chain = new ClickHouseBinaryLogicalOperation(chain, + new ClickHouseBinaryComparisonOperation(col, ClickHouseCreateConstant.createInt32Constant(bound), + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.SMALLER), + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND); + if (Randomly.getBoolean()) { + chain = new ClickHouseBinaryLogicalOperation(chain, + new ClickHouseBinaryComparisonOperation(col, + ClickHouseCreateConstant + .createInt32Constant(bound + Randomly.getNotCachedInteger(1, 40)), + ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator.GREATER), + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND); + } + return chain; + } + public ClickHouseExpression generateDateTransform(List columns) { List dateCols = new java.util.ArrayList<>(); boolean dateTimeResolution = false; @@ -717,7 +776,44 @@ public ClickHouseExpression generateWindowCall(List c } List orderBy = new java.util.ArrayList<>(); orderBy.add(columns.get((int) Randomly.getNotCachedInteger(0, columns.size()))); - return new sqlancer.clickhouse.ast.ClickHouseWindowFunction(kind, argument, partitionBy, orderBy); + return new sqlancer.clickhouse.ast.ClickHouseWindowFunction(kind, argument, partitionBy, orderBy, + pickWindowFrame(kind)); + } + + private String pickWindowFrame(sqlancer.clickhouse.ast.ClickHouseWindowFunction.Kind kind) { + if (!acceptsExplicitFrame(kind) || Randomly.getBoolean()) { + return null; + } + String mode; + if (globalState.getClickHouseOptions().groupsWindowFrameEmission) { + mode = Randomly.fromOptions("ROWS", "RANGE", "GROUPS"); + } else { + mode = Randomly.fromOptions("ROWS", "RANGE"); + } + if ("RANGE".equals(mode)) { + return "RANGE BETWEEN " + Randomly.fromOptions("UNBOUNDED PRECEDING", "CURRENT ROW") + " AND " + + Randomly.fromOptions("CURRENT ROW", "UNBOUNDED FOLLOWING"); + } + String start = Randomly.fromOptions("UNBOUNDED PRECEDING", "CURRENT ROW", + Randomly.getNotCachedInteger(0, 4) + " PRECEDING"); + String end = Randomly.fromOptions("CURRENT ROW", "UNBOUNDED FOLLOWING", + Randomly.getNotCachedInteger(0, 4) + " FOLLOWING"); + return mode + " BETWEEN " + start + " AND " + end; + } + + private static boolean acceptsExplicitFrame(sqlancer.clickhouse.ast.ClickHouseWindowFunction.Kind kind) { + switch (kind) { + case SUM: + case COUNT: + case MIN: + case MAX: + case AVG: + case FIRST_VALUE: + case LAST_VALUE: + return true; + default: + return false; + } } public ClickHouseExpression generateDictGet(String dictName, ClickHouseColumnReference keyCol) { @@ -1513,6 +1609,16 @@ public ClickHouseExpression generatePredicate() { } } + if (globalState.getClickHouseOptions().comparisonChainEmission + && Randomly.getBooleanWithRatherLowProbability()) { + ClickHouseExpression chain = generateComparisonChain(columnRefs); + if (chain != null) { + return Randomly.getBoolean() ? chain + : new ClickHouseBinaryLogicalOperation(base, chain, + ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND); + } + } + if (ClickHouseVariantPredicateFactory.gateOpen(globalState.getClickHouseOptions().variantWhereEmission, Randomly.getBooleanWithSmallProbability())) { List intExprs = integerColumns(columnRefs).stream() diff --git a/src/sqlancer/clickhouse/gen/ClickHouseInsertGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseInsertGenerator.java index 1781c3e84..e806370be 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseInsertGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseInsertGenerator.java @@ -21,6 +21,7 @@ public class ClickHouseInsertGenerator extends AbstractInsertGenerator defaultHeavyColumns = java.util.Collections.emptySet(); public ClickHouseInsertGenerator(ClickHouseGlobalState globalState) { this.globalState = globalState; @@ -58,15 +59,79 @@ public void buildStatement() { } columns = withSign; } + defaultHeavyColumns = pickDefaultHeavyColumns(table, columns); buildInsertInto(table.getName(), columns); } + private java.util.Set pickDefaultHeavyColumns(ClickHouseTable table, List columns) { + if (!globalState.getClickHouseOptions().sparseColumnEmission || !"MergeTree".equals(table.getEngine()) + || Randomly.getBoolean()) { + return java.util.Collections.emptySet(); + } + java.util.Set picked = new java.util.HashSet<>(); + for (ClickHouseColumn c : columns) { + if (defaultLiteral(c) != null && Randomly.getBoolean()) { + picked.add(c.getName()); + } + } + return picked; + } + + private static String defaultLiteral(ClickHouseColumn column) { + sqlancer.clickhouse.ClickHouseType term = column.getType().getTypeTerm(); + if (term instanceof sqlancer.clickhouse.ClickHouseType.Nullable) { + return "NULL"; + } + sqlancer.clickhouse.ClickHouseType unwrapped = term.unwrap(); + if (unwrapped instanceof sqlancer.clickhouse.ClickHouseType.Array) { + return "[]"; + } + if (unwrapped instanceof sqlancer.clickhouse.ClickHouseType.FixedString) { + return "''"; + } + switch (column.getType().getType()) { + case Int8: + case Int16: + case Int32: + case Int64: + case Int128: + case Int256: + case UInt8: + case UInt16: + case UInt32: + case UInt64: + case UInt128: + case UInt256: + case Float32: + case Float64: + case Bool: + return "0"; + case String: + return "''"; + case Date: + case Date32: + return "'1970-01-01'"; + case DateTime: + case DateTime32: + return "'1970-01-01 00:00:00'"; + default: + return null; + } + } + @Override protected void insertValue(ClickHouseColumn column) { if (signConstrained && column.getType().getType() == ClickHouseDataType.Int8) { sb.append(Randomly.getBoolean() ? "1" : "-1"); return; } + if (defaultHeavyColumns.contains(column.getName()) && !Randomly.getBooleanWithSmallProbability()) { + String literal = defaultLiteral(column); + if (literal != null) { + sb.append(literal); + return; + } + } String s = ClickHouseToStringVisitor.asString(gen.generateConstant(column.getType())); sb.append(s); } diff --git a/src/sqlancer/clickhouse/gen/ClickHouseStatisticsGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseStatisticsGenerator.java index b8d51f2dd..ab09202e4 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseStatisticsGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseStatisticsGenerator.java @@ -12,11 +12,18 @@ public final class ClickHouseStatisticsGenerator { - private static final List KINDS = List.of("tdigest", "uniq", "countmin", "minmax"); + public static final List KINDS = List.of("tdigest", "uniq", "countmin", "minmax", "uniq_v2", "basic"); private ClickHouseStatisticsGenerator() { } + public static String pickKinds() { + List pool = new java.util.ArrayList<>(KINDS); + java.util.Collections.shuffle(pool, new java.util.Random(Randomly.getNotCachedInteger(0, Integer.MAX_VALUE))); + int n = 1 + (int) Randomly.getNotCachedInteger(0, 2); + return String.join(", ", pool.subList(0, Math.min(n, pool.size()))); + } + public static ClickHouseAlterStatistics buildStatement(ClickHouseGlobalState state) { List tables = state.getSchema().getDatabaseTables().stream().filter(t -> !t.isView()) .collect(Collectors.toList()); @@ -24,15 +31,22 @@ public static ClickHouseAlterStatistics buildStatement(ClickHouseGlobalState sta throw new IgnoreMeException(); } ClickHouseTable table = Randomly.fromList(tables); - ClickHouseColumn col = Randomly.fromList(table.getColumns()); + List statisticsColumns = table.getColumns().stream().filter(c -> !c.isAlias()) + .collect(Collectors.toList()); + if (statisticsColumns.isEmpty()) { + throw new IgnoreMeException(); + } + ClickHouseColumn col = Randomly.fromList(statisticsColumns); String fq = state.getDatabaseName() + "." + table.getName(); ClickHouseAlterStatistics.Kind kind = Randomly.fromOptions(ClickHouseAlterStatistics.Kind.values()); String sql; switch (kind) { + case ADD_STATISTICS: + sql = "ALTER TABLE " + fq + " ADD STATISTICS IF NOT EXISTS " + col.getName() + " TYPE " + pickKinds(); + break; case MODIFY_STATISTICS: - String kind1 = Randomly.fromList(KINDS); - sql = "ALTER TABLE " + fq + " MODIFY STATISTICS " + col.getName() + " TYPE " + kind1; + sql = "ALTER TABLE " + fq + " MODIFY STATISTICS " + col.getName() + " TYPE " + pickKinds(); break; case MATERIALIZE_STATISTICS: sql = "ALTER TABLE " + fq + " MATERIALIZE STATISTICS " + col.getName(); diff --git a/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java b/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java index c5770127a..f207928ad 100644 --- a/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java +++ b/src/sqlancer/clickhouse/gen/ClickHouseTableGenerator.java @@ -138,11 +138,15 @@ public void start() { if (bareCols.size() >= 2) { java.util.List obCols = pickDistinct(bareCols, 2 + (int) Randomly.getNotCachedInteger(0, Math.min(2, bareCols.size() - 1))); - int pkCount = 1 + (int) Randomly.getNotCachedInteger(0, obCols.size() - 1); - sb.append(" ORDER BY (").append(String.join(", ", obCols)).append(")"); - java.util.List pkCols = obCols.subList(0, pkCount); - primaryKeyClause = " PRIMARY KEY (" + String.join(", ", pkCols) + ")"; - sampleByColumn = firstBareUnsignedIntIn(pkCols); + java.util.List directed = withSortDirections(obCols); + boolean descending = !directed.equals(obCols); + sb.append(" ORDER BY (").append(String.join(", ", directed)).append(")"); + if (!descending) { + int pkCount = 1 + (int) Randomly.getNotCachedInteger(0, obCols.size() - 1); + java.util.List pkCols = obCols.subList(0, pkCount); + primaryKeyClause = " PRIMARY KEY (" + String.join(", ", pkCols) + ")"; + sampleByColumn = firstBareUnsignedIntIn(pkCols); + } orderByHandled = true; } } @@ -162,8 +166,12 @@ public void start() { if (expr != null) { sb.append(" ORDER BY "); sb.append(ClickHouseToStringVisitor.asString(expr)); - sampleByColumn = bareIntegerColumnName(expr); + if (!isDedupeEngine(engine) && descendingKeysAllowed() + && Randomly.getBooleanWithRatherLowProbability()) { + sb.append(" DESC"); + sampleByColumn = null; + } } else { sb.append(fallbackOrderBy); sampleByColumn = fallbackSampleColumn(engineRequiresNonEmptyOrderBy); @@ -250,6 +258,34 @@ static boolean isDedupeKeyColumn(ClickHouseSchema.ClickHouseColumn col) { return isBareKeyColumn(col) && !hasDegenerateKeyDomain(col); } + private static String renderAutoStatisticsTypes() { + List pool = new ArrayList<>(ClickHouseStatisticsGenerator.KINDS); + java.util.Collections.shuffle(pool, new java.util.Random(Randomly.getNotCachedInteger(0, Integer.MAX_VALUE))); + int n = (int) Randomly.getNotCachedInteger(0, 4); + return String.join(", ", pool.subList(0, Math.min(n, pool.size()))); + } + + private boolean descendingKeysAllowed() { + return globalState.getClickHouseOptions().mixedDirectionSortingKey; + } + + private java.util.List withSortDirections(java.util.List orderByColumns) { + if (!descendingKeysAllowed() || Randomly.getBoolean()) { + return orderByColumns; + } + java.util.List out = new java.util.ArrayList<>(orderByColumns.size()); + boolean anyDescending = false; + for (String col : orderByColumns) { + boolean descending = Randomly.getBoolean(); + anyDescending |= descending; + out.add(descending ? col + " DESC" : col); + } + if (!anyDescending) { + out.set(out.size() - 1, orderByColumns.get(out.size() - 1) + " DESC"); + } + return out; + } + static java.util.List pickDistinct(java.util.List src, int k) { java.util.List pool = new java.util.ArrayList<>(src); java.util.List out = new java.util.ArrayList<>(); @@ -369,14 +405,22 @@ private String renderMergeTreeSettings() { if (Randomly.getBooleanWithSmallProbability()) { settings.add("merge_max_block_size=" + Randomly.fromOptions(1L, 1024L, 8192L)); } - if (Randomly.getBooleanWithSmallProbability()) { - settings.add("ratio_of_defaults_for_sparse_serialization=" - + Randomly.fromOptions(0.0, 0.5, 0.95, 1.0)); + if (globalState.getClickHouseOptions().sparseColumnEmission ? Randomly.getBoolean() + : Randomly.getBooleanWithSmallProbability()) { + settings.add( + "ratio_of_defaults_for_sparse_serialization=" + Randomly.fromOptions(0.0, 0.1, 0.3, 0.5, 0.95, + 1.0)); } if (Randomly.getBooleanWithSmallProbability()) { settings.add("min_compress_block_size=" + Randomly.fromOptions(0L, 65536L)); settings.add("max_compress_block_size=" + Randomly.fromOptions(65536L, 1048576L)); } + if (Randomly.getBooleanWithSmallProbability()) { + settings.add("auto_statistics_types='" + renderAutoStatisticsTypes() + "'"); + } + if (Randomly.getBooleanWithSmallProbability()) { + settings.add("materialize_statistics_on_merge=" + Randomly.fromOptions(0, 1)); + } if (Randomly.getBooleanWithRatherLowProbability()) { settings.add("enable_block_number_column=1"); settings.add("enable_block_offset_column=1"); @@ -481,9 +525,16 @@ private String renderSkipIndex(int idx, ClickHouseSchema.ClickHouseColumn col) { granularity); } - private static String pickTokenizer() { - return Randomly.fromOptions("'splitByNonAlpha'", "ngrams(2)", "ngrams(3)", "ngrams(4)", "'array'", "'asciiCJK'", - "splitByString([' '])", "splitByString([' ', '-', '::'])", "sparseGrams(3, 5)"); + private String pickTokenizer() { + List tokenizers = new ArrayList<>(List.of("'splitByNonAlpha'", "ngrams(2)", "ngrams(3)", "ngrams(4)", + "'array'", "'asciiCJK'", "splitByString([' '])", "splitByString([' ', '-', '::'])", + "sparseGrams(3, 5)")); + if (globalState.getClickHouseOptions().textIndexSecondWave) { + tokenizers.add("icu('en')"); + tokenizers.add("icu('de')"); + tokenizers.add("icu('ja')"); + } + return Randomly.fromList(tokenizers); } private static String textIndexTarget(ClickHouseSchema.ClickHouseColumn col) { diff --git a/src/sqlancer/clickhouse/oracle/final_/ClickHouseTupleFinalAggregationOracle.java b/src/sqlancer/clickhouse/oracle/final_/ClickHouseTupleFinalAggregationOracle.java new file mode 100644 index 000000000..16441323a --- /dev/null +++ b/src/sqlancer/clickhouse/oracle/final_/ClickHouseTupleFinalAggregationOracle.java @@ -0,0 +1,224 @@ +package sqlancer.clickhouse.oracle.final_; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import sqlancer.ComparatorHelper; +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.common.oracle.TestOracle; +import sqlancer.common.query.ExpectedErrors; +import sqlancer.common.query.SQLQueryAdapter; + +public class ClickHouseTupleFinalAggregationOracle implements TestOracle { + + private static final AtomicLong TUPLE_COUNTER = new AtomicLong(); + private static final int KEYS = 6; + + private final ClickHouseGlobalState state; + private final ExpectedErrors errors = new ExpectedErrors(); + + public ClickHouseTupleFinalAggregationOracle(ClickHouseGlobalState state) { + this.state = state; + ClickHouseErrors.addExpectedExpressionErrors(errors); + ClickHouseErrors.addSessionSettingsErrors(errors); + errors.add("UNKNOWN_TABLE"); + errors.add("(MEMORY_LIMIT_EXCEEDED)"); + errors.add("memory limit exceeded"); + errors.add("TIMEOUT_EXCEEDED"); + errors.add("Timeout exceeded"); + errors.add("Limit for result exceeded"); + errors.add("TOO_MANY_ROWS_OR_BYTES"); + errors.add("allow_tuple_element_aggregation"); + errors.add("Unknown setting"); + errors.add("UNKNOWN_SETTING"); + errors.add("UNKNOWN_STORAGE"); + errors.add("Unknown table engine"); + } + + @Override + public void check() throws SQLException { + if (!state.getClickHouseOptions().tupleFinalAggregationOracle) { + throw new IgnoreMeException(); + } + boolean summing = Randomly.getBoolean(); + long id = TUPLE_COUNTER.incrementAndGet(); + String table = state.getDatabaseName() + ".tuplefin_" + id; + try { + if (summing) { + checkSumming(table); + } else { + checkCoalescing(table); + } + } finally { + dropQuietly(table); + } + } + + private void checkSumming(String table) throws SQLException { + String tupleType = Randomly.getBoolean() ? "Tuple(Int64, Int64)" : "Tuple(a Int64, b Int64)"; + String create = "CREATE TABLE " + table + " (k UInt32, t " + tupleType + + ", v Int64) ENGINE = SummingMergeTree ORDER BY k SETTINGS allow_tuple_element_aggregation = 1"; + create(create); + + Map model = new LinkedHashMap<>(); + int blocks = 2 + (int) Randomly.getNotCachedInteger(0, 3); + for (int b = 0; b < blocks; b++) { + StringBuilder sb = new StringBuilder("INSERT INTO ").append(table).append(" (k, t, v) VALUES "); + for (int i = 0; i < KEYS; i++) { + long k = i; + long t1 = Randomly.getNotCachedInteger(-50, 51); + long t2 = Randomly.getNotCachedInteger(-50, 51); + long v = 1 + Randomly.getNotCachedInteger(0, 20); + if (i > 0) { + sb.append(", "); + } + sb.append('(').append(k).append(", (").append(t1).append(", ").append(t2).append("), ").append(v) + .append(')'); + long[] acc = model.computeIfAbsent(k, x -> new long[3]); + acc[0] += t1; + acc[1] += t2; + acc[2] += v; + } + insert(sb.toString()); + } + + List expected = new ArrayList<>(); + for (Map.Entry e : model.entrySet()) { + long[] acc = e.getValue(); + expected.add("(" + e.getKey() + "," + acc[0] + "," + acc[1] + "," + acc[2] + ")"); + } + + String projection = "toString(tuple(k, t.1, t.2, v))"; + assertGroundTruth(table, projection, expected, "SummingMergeTree per-element Tuple summation"); + assertQueryTimeMatchesPhysicalFinal(table, projection); + if (state.getClickHouseOptions().summingSubsetProjectionArm) { + assertSubsetProjectionMatchesPhysicalFinal(table); + } + } + + private void checkCoalescing(String table) throws SQLException { + String create = "CREATE TABLE " + table + + " (k UInt32, t Tuple(Nullable(Int64), Nullable(Int64)), v Nullable(Int64)) " + + "ENGINE = CoalescingMergeTree ORDER BY k SETTINGS allow_tuple_element_aggregation = 1"; + create(create); + + Map model = new LinkedHashMap<>(); + for (int slot = 0; slot < 3; slot++) { + StringBuilder sb = new StringBuilder("INSERT INTO ").append(table).append(" (k, t, v) VALUES "); + for (int i = 0; i < KEYS; i++) { + long k = i; + long value = 1 + Randomly.getNotCachedInteger(0, 100); + String t1 = slot == 0 ? String.valueOf(value) : "NULL"; + String t2 = slot == 1 ? String.valueOf(value) : "NULL"; + String v = slot == 2 ? String.valueOf(value) : "NULL"; + if (i > 0) { + sb.append(", "); + } + sb.append('(').append(k).append(", (").append(t1).append(", ").append(t2).append("), ").append(v) + .append(')'); + long[] acc = model.computeIfAbsent(k, x -> new long[3]); + acc[slot] = value; + } + insert(sb.toString()); + } + + List expected = new ArrayList<>(); + for (Map.Entry e : model.entrySet()) { + long[] acc = e.getValue(); + expected.add("(" + e.getKey() + "," + acc[0] + "," + acc[1] + "," + acc[2] + ")"); + } + + String projection = "toString(tuple(k, t.1, t.2, v))"; + assertGroundTruth(table, projection, expected, + "CoalescingMergeTree per-element Tuple coalescing (each element is non-NULL in exactly one part, " + + "so the outcome does not depend on merge order)"); + assertQueryTimeMatchesPhysicalFinal(table, projection); + } + + private void assertGroundTruth(String table, String projection, List expected, String what) + throws SQLException { + String query = "SELECT " + projection + " FROM " + table + " FINAL ORDER BY k"; + log(query); + List actual = ComparatorHelper.getResultSetFirstColumnAsString(query, errors, state); + if (!expected.equals(actual)) { + throw new AssertionError(String.format( + "%s disagrees with the Java ground truth over the inserted rows.%n Q: %s%n expected (%d): %s%n" + + " actual (%d): %s", + what, query, expected.size(), expected, actual.size(), actual)); + } + } + + private void assertQueryTimeMatchesPhysicalFinal(String table, String projection) throws SQLException { + String queryTime = "SELECT " + projection + " FROM " + table + " FINAL ORDER BY k"; + log(queryTime); + List before = ComparatorHelper.getResultSetFirstColumnAsString(queryTime, errors, state); + + String optimize = "OPTIMIZE TABLE " + table + " FINAL"; + log(optimize); + if (!new SQLQueryAdapter(optimize, errors, false).execute(state)) { + throw new IgnoreMeException(); + } + + String physical = "SELECT " + projection + " FROM " + table + " ORDER BY k"; + log(physical); + List after = ComparatorHelper.getResultSetFirstColumnAsString(physical, errors, state); + if (!before.equals(after)) { + throw new AssertionError(String.format( + "query-time FINAL and a physical OPTIMIZE ... FINAL disagree.%n query-time: %s%n physical: %s%n" + + " query-time rows (%d): %s%n physical rows (%d): %s", + queryTime, physical, before.size(), before, after.size(), after)); + } + } + + private void assertSubsetProjectionMatchesPhysicalFinal(String table) throws SQLException { + String subset = Randomly.fromOptions("toString(tuple(k, v))", "toString(tuple(k, t.1))", "toString(k)"); + String queryTime = "SELECT " + subset + " FROM " + table + " FINAL ORDER BY k"; + log(queryTime); + List before = ComparatorHelper.getResultSetFirstColumnAsString(queryTime, errors, state); + String physical = "SELECT " + subset + " FROM " + table + " ORDER BY k"; + log(physical); + List after = ComparatorHelper.getResultSetFirstColumnAsString(physical, errors, state); + if (!before.equals(after)) { + throw new AssertionError(String.format( + "query-time FINAL over a subset of the summed columns dropped or kept rows differently from a " + + "physical OPTIMIZE ... FINAL (ClickHouse #106125 shape).%n query-time: %s%n" + + " physical: %s%n query-time rows (%d): %s%n physical rows (%d): %s", + queryTime, physical, before.size(), before, after.size(), after)); + } + } + + private void create(String ddl) throws SQLException { + log(ddl); + if (!new SQLQueryAdapter(ddl, errors, true).execute(state)) { + throw new IgnoreMeException(); + } + } + + private void insert(String stmt) throws SQLException { + log(stmt); + if (!new SQLQueryAdapter(stmt, errors, true).execute(state)) { + throw new IgnoreMeException(); + } + } + + private void log(String sql) { + if (state.getOptions().logEachSelect()) { + state.getLogger().writeCurrent(sql); + state.getState().logStatement(sql); + } + } + + private void dropQuietly(String table) { + try { + new SQLQueryAdapter("DROP TABLE IF EXISTS " + table, errors, true).execute(state); + } catch (Exception | AssertionError ignored) { + } + } +} diff --git a/src/sqlancer/clickhouse/oracle/join/ClickHouseIEJoinOracle.java b/src/sqlancer/clickhouse/oracle/join/ClickHouseIEJoinOracle.java new file mode 100644 index 000000000..07202f4a5 --- /dev/null +++ b/src/sqlancer/clickhouse/oracle/join/ClickHouseIEJoinOracle.java @@ -0,0 +1,175 @@ +package sqlancer.clickhouse.oracle.join; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicLong; + +import sqlancer.ComparatorHelper; +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.common.oracle.TestOracle; +import sqlancer.common.query.ExpectedErrors; +import sqlancer.common.query.SQLQueryAdapter; + +public class ClickHouseIEJoinOracle implements TestOracle { + + private static final AtomicLong IE_COUNTER = new AtomicLong(); + private static final int DIFF_LIMIT = 20; + private static final String CAPS = "max_result_rows = 1000000, result_overflow_mode = 'throw', " + + "max_bytes_in_join = 268435456, max_memory_usage = 1073741824"; + + private final ClickHouseGlobalState state; + private final ExpectedErrors errors = new ExpectedErrors(); + + public ClickHouseIEJoinOracle(ClickHouseGlobalState state) { + this.state = state; + ClickHouseErrors.addExpectedExpressionErrors(errors); + ClickHouseErrors.addSessionSettingsErrors(errors); + errors.add("UNKNOWN_TABLE"); + errors.add("(MEMORY_LIMIT_EXCEEDED)"); + errors.add("memory limit exceeded"); + errors.add("TIMEOUT_EXCEEDED"); + errors.add("Timeout exceeded"); + errors.add("Limit for result exceeded"); + errors.add("TOO_MANY_ROWS_OR_BYTES"); + errors.add("Limit for JOIN exceeded"); + errors.add("Can't execute any of specified algorithms"); + errors.add("INVALID_JOIN_ON_EXPRESSION"); + errors.add("Cannot determine join keys"); + errors.add("UNKNOWN_JOIN"); + } + + @Override + public void check() throws SQLException { + if (!state.getClickHouseOptions().ieJoinOracle) { + throw new IgnoreMeException(); + } + long id = IE_COUNTER.incrementAndGet(); + String left = state.getDatabaseName() + ".iejoin_" + id + "_l"; + String right = state.getDatabaseName() + ".iejoin_" + id + "_r"; + try { + createAndSeed(left); + createAndSeed(right); + + String leftOp = Randomly.fromOptions("<", "<="); + String rightOp = Randomly.fromOptions(">", ">="); + String on = "l.x " + leftOp + " r.x AND l.y " + rightOp + " r.y"; + String projection = "toString(tuple(l.k, r.k))"; + + String ieJoin = "SELECT " + projection + " FROM " + left + " AS l INNER JOIN " + right + " AS r ON " + on + + " SETTINGS join_algorithm = 'ie_join', " + CAPS; + String crossJoin = "SELECT " + projection + " FROM " + left + " AS l CROSS JOIN " + right + " AS r WHERE " + + on + " SETTINGS " + CAPS; + + log(ieJoin); + List ieRows = ComparatorHelper.getResultSetFirstColumnAsString(ieJoin, errors, state); + log(crossJoin); + List crossRows = ComparatorHelper.getResultSetFirstColumnAsString(crossJoin, errors, state); + + List diff = multisetDiff(crossRows, ieRows, DIFF_LIMIT); + if (!diff.isEmpty()) { + throw new AssertionError(String.format( + "IEJoin mismatch: a join whose ON carries two inequality comparisons returned %d rows under " + + "join_algorithm = 'ie_join' but the equivalent CROSS JOIN with the same two " + + "comparisons in WHERE returned %d.%n ie_join: %s%n cross join: %s%n" + + " first %d differing (left key, right key) pairs: %s", + ieRows.size(), crossRows.size(), ieJoin, crossJoin, diff.size(), diff)); + } + + checkLeftPreservation(left, right, on); + } finally { + dropQuietly(left); + dropQuietly(right); + } + } + + private void checkLeftPreservation(String left, String right, String on) throws SQLException { + String outer = "SELECT toString(count(DISTINCT l.k)) FROM " + left + " AS l LEFT JOIN " + right + " AS r ON " + + on + " SETTINGS join_algorithm = 'ie_join', " + CAPS; + String total = "SELECT toString(count()) FROM " + left; + log(outer); + long joined = scalar(outer); + long rows = scalar(total); + if (joined != rows) { + throw new AssertionError(String.format( + "IEJoin LEFT-preservation violation: a LEFT JOIN must emit at least one row for every left row, " + + "so the number of distinct left keys in its output (%d) must equal the left table's row " + + "count (%d).%n Q: %s", + joined, rows, outer)); + } + } + + private void createAndSeed(String table) throws SQLException { + String create = "CREATE TABLE " + table + " (k Int64, x Int64, y Int64) ENGINE = MergeTree ORDER BY k"; + log(create); + if (!new SQLQueryAdapter(create, errors, true).execute(state)) { + throw new IgnoreMeException(); + } + int rows = 8 + (int) Randomly.getNotCachedInteger(0, 25); + StringBuilder sb = new StringBuilder("INSERT INTO ").append(table).append(" (k, x, y) VALUES "); + for (int i = 0; i < rows; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('(').append(i).append(", ").append(Randomly.getNotCachedInteger(-30, 30)).append(", ") + .append(Randomly.getNotCachedInteger(-30, 30)).append(')'); + } + log(sb.toString()); + if (!new SQLQueryAdapter(sb.toString(), errors, true).execute(state)) { + throw new IgnoreMeException(); + } + } + + private long scalar(String query) throws SQLException { + List rows = ComparatorHelper.getResultSetFirstColumnAsString(query, errors, state); + if (rows.size() != 1 || rows.get(0) == null) { + throw new IgnoreMeException(); + } + try { + return Long.parseLong(rows.get(0).trim()); + } catch (NumberFormatException e) { + throw new IgnoreMeException(); + } + } + + private static List multisetDiff(List a, List b, int limit) { + Map counts = new TreeMap<>(); + for (String s : a) { + counts.merge(s == null ? "\\N" : s, 1L, Long::sum); + } + for (String s : b) { + counts.merge(s == null ? "\\N" : s, -1L, Long::sum); + } + List diff = new ArrayList<>(); + for (Map.Entry e : counts.entrySet()) { + if (e.getValue() == 0) { + continue; + } + if (diff.size() >= limit) { + break; + } + long c = e.getValue(); + diff.add(e.getKey() + " (+" + Math.abs(c) + " " + (c > 0 ? "cross join" : "ie_join") + ")"); + } + return diff; + } + + private void log(String sql) { + if (state.getOptions().logEachSelect()) { + state.getLogger().writeCurrent(sql); + state.getState().logStatement(sql); + } + } + + private void dropQuietly(String table) { + try { + new SQLQueryAdapter("DROP TABLE IF EXISTS " + table, errors, true).execute(state); + } catch (Exception | AssertionError ignored) { + } + } +} diff --git a/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinAlgorithmOracle.java b/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinAlgorithmOracle.java index da810b8fd..9aa574c24 100644 --- a/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinAlgorithmOracle.java +++ b/src/sqlancer/clickhouse/oracle/join/ClickHouseJoinAlgorithmOracle.java @@ -56,6 +56,7 @@ public void check() throws SQLException { String qMerge = baseQuery + " SETTINGS join_algorithm = 'partial_merge', " + CAPS; String qGrace = baseQuery + " SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = " + Randomly.fromOptions(1, 4, 32) + ", " + CAPS; + String qParallelSort = baseQuery + " SETTINGS join_algorithm = 'parallel_full_sorting_merge', " + CAPS; List rowsHash; try { @@ -65,8 +66,10 @@ public void check() throws SQLException { } List rowsMerge = ComparatorHelper.getResultSetFirstColumnAsString(qMerge, errors, state); List rowsGrace = ComparatorHelper.getResultSetFirstColumnAsString(qGrace, errors, state); + List rowsParallelSort = ComparatorHelper.getResultSetFirstColumnAsString(qParallelSort, errors, state); ComparatorHelper.assumeResultSetsAreEqual(rowsHash, rowsMerge, qHash, List.of(qMerge), state); ComparatorHelper.assumeResultSetsAreEqual(rowsHash, rowsGrace, qHash, List.of(qGrace), state); + ComparatorHelper.assumeResultSetsAreEqual(rowsHash, rowsParallelSort, qHash, List.of(qParallelSort), state); } static boolean isAlgorithmDeterministic(ClickHouseJoin.JoinType type) { diff --git a/src/sqlancer/clickhouse/oracle/keycond/ClickHouseKeyConditionOracle.java b/src/sqlancer/clickhouse/oracle/keycond/ClickHouseKeyConditionOracle.java index 57cdfcc5c..f96237aa5 100644 --- a/src/sqlancer/clickhouse/oracle/keycond/ClickHouseKeyConditionOracle.java +++ b/src/sqlancer/clickhouse/oracle/keycond/ClickHouseKeyConditionOracle.java @@ -69,6 +69,68 @@ public void check() throws SQLException { } List noPruneRows = ComparatorHelper.getResultSetFirstColumnAsString(noPrune, errors, state); ComparatorHelper.assumeResultSetsAreEqual(baseRows, noPruneRows, baseline, List.of(noPrune), state); + + if (state.getClickHouseOptions().indexHintEmission && Randomly.getBoolean()) { + checkIndexHintIsResultNeutral(select, gen, columns); + } + } + + private void checkIndexHintIsResultNeutral(ClickHouseSelect select, ClickHouseExpressionGenerator gen, + List columns) throws SQLException { + ClickHouseExpression retained = select.getWhereClause(); + String pinned = " SETTINGS force_primary_key = 0, convert_query_to_cnf = 0, use_query_condition_cache = 0"; + String outerOnly = ClickHouseToStringVisitor.asString(select) + pinned; + + ClickHouseColumnReference hintColumn = columns.get((int) Randomly.getNotCachedInteger(0, columns.size())); + ClickHouseExpression hintArgument = new sqlancer.clickhouse.ast.ClickHouseBinaryComparisonOperation(hintColumn, + gen.generateConstant(hintColumn.getColumn().getType()), + sqlancer.clickhouse.ast.ClickHouseBinaryComparisonOperation.ClickHouseBinaryComparisonOperator + .getRandomOperator()); + ClickHouseExpression hint = new sqlancer.clickhouse.ast.ClickHouseWrappedExpression("indexHint(", + hintArgument, ")"); + select.setWhereClause(new sqlancer.clickhouse.ast.ClickHouseBinaryLogicalOperation(hint, retained, + sqlancer.clickhouse.ast.ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND)); + String hinted = ClickHouseToStringVisitor.asString(select) + pinned; + select.setWhereClause(new sqlancer.clickhouse.ast.ClickHouseBinaryLogicalOperation(hintArgument, retained, + sqlancer.clickhouse.ast.ClickHouseBinaryLogicalOperation.ClickHouseBinaryLogicalOperator.AND)); + String bothFilters = ClickHouseToStringVisitor.asString(select) + pinned; + select.setWhereClause(retained); + + List outerRows = ComparatorHelper.getResultSetFirstColumnAsString(outerOnly, errors, state); + List hintedRows = ComparatorHelper.getResultSetFirstColumnAsString(hinted, errors, state); + List bothRows = ComparatorHelper.getResultSetFirstColumnAsString(bothFilters, errors, state); + + if (!isSubMultiset(bothRows, hintedRows)) { + throw new AssertionError(String.format( + "indexHint dropped a matching row: indexHint(P) does not filter, it only restricts the granules " + + "the read touches, so every row selected by 'P AND Q' must also be selected by " + + "'indexHint(P) AND Q'. A missing row means index analysis pruned a granule that holds a " + + "row satisfying P.%n P AND Q (%d rows): %s%n indexHint(P) AND Q (%d rows): %s", + bothRows.size(), bothFilters, hintedRows.size(), hinted)); + } + if (!isSubMultiset(hintedRows, outerRows)) { + throw new AssertionError(String.format( + "indexHint added a row: it can only remove whole granules from the read, so " + + "'indexHint(P) AND Q' must be a sub-multiset of 'Q'.%n" + + " indexHint(P) AND Q (%d rows): %s%n Q (%d rows): %s", + hintedRows.size(), hinted, outerRows.size(), outerOnly)); + } + } + + private static boolean isSubMultiset(List sub, List sup) { + java.util.Map counts = new java.util.HashMap<>(); + for (String v : sup) { + counts.merge(v == null ? "\\N" : v, 1L, Long::sum); + } + for (String v : sub) { + String key = v == null ? "\\N" : v; + long remaining = counts.getOrDefault(key, 0L) - 1; + if (remaining < 0) { + return false; + } + counts.put(key, remaining); + } + return true; } static final class MaterializedColumnVisitor extends ClickHouseToStringVisitor { diff --git a/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java b/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java index 71715eed1..8f8f2edbb 100644 --- a/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java +++ b/src/sqlancer/clickhouse/oracle/limit/ClickHouseLimitRankingOracle.java @@ -22,7 +22,10 @@ public class ClickHouseLimitRankingOracle implements TestOracle quote(c.getName()) + " ASC").collect(Collectors.joining(", ")); String base = "SELECT " + projection + " FROM " + tableQ; - Mode mode = Randomly.fromOptions(Mode.values()); + Mode mode = pickMode(); switch (mode) { case OFFSET_FORM_EQUIVALENCE: checkOffsetFormEquivalence(base, totalOrder); @@ -84,11 +87,103 @@ public void check() throws SQLException { case LIMIT_BY_CAP: checkLimitByCap(tableQ, totalOrder, columns); break; + case NEGATIVE_LIMIT_TAIL: + checkNegativeLimitTail(base, totalOrder); + break; + case NEGATIVE_LIMIT_BY_REVERSAL: + checkNegativeLimitByReversal(tableQ, columns); + break; + case NEGATIVE_WITH_TIES_SUPERSET: + checkNegativeWithTiesSuperset(base, columns); + break; default: throw new AssertionError(mode); } } + private Mode pickMode() { + if (state.getClickHouseOptions().negativeLimitEmission) { + return Randomly.fromOptions(Mode.values()); + } + return Randomly.fromOptions(Mode.OFFSET_FORM_EQUIVALENCE, Mode.WITH_TIES_SUPERSET, Mode.LIMIT_BY_CAP); + } + + private void checkNegativeLimitTail(String base, String totalOrder) throws SQLException { + long n = 1 + Randomly.getNotCachedInteger(0, 10); + String ascOrder = totalOrder.replace(" ASC", " ASC NULLS LAST"); + String reverseOrder = totalOrder.replace(" ASC", " DESC NULLS FIRST"); + String tail = base + " ORDER BY " + ascOrder + " LIMIT -" + n; + String head = base + " ORDER BY " + reverseOrder + " LIMIT " + n; + + List tailRows = ComparatorHelper.getResultSetFirstColumnAsString(tail, readErrors, state); + List headRows = ComparatorHelper.getResultSetFirstColumnAsString(head, readErrors, state); + + List tailSorted = sorted(tailRows); + List headSorted = sorted(headRows); + if (!tailSorted.equals(headSorted)) { + throw new AssertionError(String.format( + "LimitRanking negative-LIMIT mismatch: 'LIMIT -%d' takes the last %d rows of the ascending total " + + "order, which must be the same multiset as 'LIMIT %d' over the reverse total order.%n" + + " tail: %s%n head: %s%n tail rows (%d): %s%n head rows (%d): %s", + n, n, n, tail, head, tailRows.size(), truncate(tailSorted), headRows.size(), truncate(headSorted))); + } + } + + private void checkNegativeLimitByReversal(String tableQ, List columns) throws SQLException { + String key = quote(Randomly.fromList(columns).getName()); + String projection = "toString(tuple(" + columns.stream().map(c -> quote(c.getName())) + .collect(Collectors.joining(", ")) + "))"; + String ascOrder = columns.stream().map(c -> quote(c.getName()) + " ASC NULLS LAST") + .collect(Collectors.joining(", ")); + String descOrder = columns.stream().map(c -> quote(c.getName()) + " DESC NULLS FIRST") + .collect(Collectors.joining(", ")); + long n = 1 + Randomly.getNotCachedInteger(0, 5); + + String tail = "SELECT " + projection + " FROM " + tableQ + " ORDER BY " + ascOrder + " LIMIT -" + n + " BY " + + key; + String head = "SELECT " + projection + " FROM " + tableQ + " ORDER BY " + descOrder + " LIMIT " + n + " BY " + + key; + + List tailRows = sorted(ComparatorHelper.getResultSetFirstColumnAsString(tail, readErrors, state)); + List headRows = sorted(ComparatorHelper.getResultSetFirstColumnAsString(head, readErrors, state)); + + if (!tailRows.equals(headRows)) { + throw new AssertionError(String.format( + "LimitRanking negative-LIMIT-BY mismatch: 'LIMIT -%d BY %s' keeps the last %d rows per key in the " + + "ascending total order, which must be the same multiset as 'LIMIT %d BY %s' over the " + + "reverse total order.%n tail: %s%n head: %s%n tail rows (%d): %s%n head rows (%d): %s", + n, key, n, n, key, tail, head, tailRows.size(), truncate(tailRows), headRows.size(), + truncate(headRows))); + } + } + + private void checkNegativeWithTiesSuperset(String base, List columns) throws SQLException { + String key = quote(Randomly.fromList(columns).getName()); + long n = 1 + Randomly.getNotCachedInteger(0, 20); + String plain = base + " ORDER BY " + key + " ASC LIMIT -" + n; + String withTies = base + " ORDER BY " + key + " ASC LIMIT -" + n + " WITH TIES"; + + List plainRows = ComparatorHelper.getResultSetFirstColumnAsString(plain, readErrors, state); + List tiesRows = ComparatorHelper.getResultSetFirstColumnAsString(withTies, readErrors, state); + + if (tiesRows.size() < plainRows.size() || !isSubMultiset(plainRows, tiesRows)) { + throw new AssertionError(String.format( + "LimitRanking negative WITH-TIES containment violation: the rows of 'LIMIT -%d' must be a " + + "sub-multiset of 'LIMIT -%d WITH TIES' under the identical ORDER BY %s.%n" + + " plain (%d): %s%n withTies (%d): %s", + n, n, key, plainRows.size(), truncate(plainRows), tiesRows.size(), truncate(tiesRows))); + } + } + + private static List sorted(List rows) { + List out = new ArrayList<>(rows.size()); + for (String r : rows) { + out.add(r == null ? "\\N" : r); + } + out.sort(String::compareTo); + return out; + } + private void checkOffsetFormEquivalence(String base, String totalOrder) throws SQLException { long a = Randomly.getNotCachedInteger(0, 20); long b = 1 + Randomly.getNotCachedInteger(0, 20); diff --git a/src/sqlancer/clickhouse/oracle/pipe/ClickHousePipeEquivalenceOracle.java b/src/sqlancer/clickhouse/oracle/pipe/ClickHousePipeEquivalenceOracle.java new file mode 100644 index 000000000..784203b4e --- /dev/null +++ b/src/sqlancer/clickhouse/oracle/pipe/ClickHousePipeEquivalenceOracle.java @@ -0,0 +1,189 @@ +package sqlancer.clickhouse.oracle.pipe; + +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +import com.clickhouse.data.ClickHouseDataType; + +import sqlancer.ComparatorHelper; +import sqlancer.IgnoreMeException; +import sqlancer.Randomly; +import sqlancer.clickhouse.ClickHouseErrors; +import sqlancer.clickhouse.ClickHouseProvider.ClickHouseGlobalState; +import sqlancer.clickhouse.ClickHouseSchema.ClickHouseColumn; +import sqlancer.clickhouse.ClickHouseSchema.ClickHouseTable; +import sqlancer.clickhouse.ClickHouseToStringVisitor; +import sqlancer.clickhouse.ast.ClickHouseColumnReference; +import sqlancer.clickhouse.gen.ClickHouseExpressionGenerator; +import sqlancer.common.oracle.TestOracle; +import sqlancer.common.query.ExpectedErrors; + +public class ClickHousePipeEquivalenceOracle implements TestOracle { + + private enum Arm { + PROJECTION, AGGREGATE, ORDER_LIMIT + } + + private final ClickHouseGlobalState state; + private final ExpectedErrors errors = new ExpectedErrors(); + + public ClickHousePipeEquivalenceOracle(ClickHouseGlobalState state) { + this.state = state; + ClickHouseErrors.addExpectedExpressionErrors(errors); + ClickHouseErrors.addSessionSettingsErrors(errors); + errors.add("UNKNOWN_TABLE"); + errors.add("Unknown table expression identifier"); + errors.add("(MEMORY_LIMIT_EXCEEDED)"); + errors.add("memory limit exceeded"); + errors.add("TIMEOUT_EXCEEDED"); + errors.add("Timeout exceeded"); + errors.add("Limit for result exceeded"); + errors.add("TOO_MANY_ROWS_OR_BYTES"); + } + + @Override + public void check() throws SQLException { + if (!state.getClickHouseOptions().pipeEquivalenceOracle) { + throw new IgnoreMeException(); + } + List tables = state.getSchema().getRandomTableNonEmptyTables().getTables().stream() + .filter(t -> !t.isView()).collect(Collectors.toList()); + if (tables.isEmpty()) { + throw new IgnoreMeException(); + } + ClickHouseTable table = Randomly.fromList(tables); + List columns = table.getColumns().stream().filter(ClickHousePipeEquivalenceOracle::isReadableByStar) + .map(c -> new ClickHouseColumnReference(c, null, "")).collect(Collectors.toList()); + if (columns.isEmpty()) { + throw new IgnoreMeException(); + } + + ClickHouseExpressionGenerator gen = new ClickHouseExpressionGenerator(state).allowAggregates(false); + gen.addColumns(columns); + String predicate = ClickHouseToStringVisitor.asString(gen.generatePredicate()); + String from = state.getDatabaseName() + "." + table.getName(); + + switch (Randomly.fromOptions(Arm.values())) { + case PROJECTION: + checkProjection(from, predicate, table); + break; + case AGGREGATE: + checkAggregate(from, predicate, table); + break; + case ORDER_LIMIT: + checkOrderLimit(from, predicate, table); + break; + default: + throw new AssertionError(); + } + } + + private void checkProjection(String from, String predicate, ClickHouseTable table) throws SQLException { + String projection = rowProjection(table); + String classic = "SELECT " + projection + " FROM " + from + " WHERE " + predicate; + String pipe = "FROM " + from + " |> WHERE " + predicate + " |> SELECT " + projection; + compareMultisets(classic, pipe); + } + + private void checkAggregate(String from, String predicate, ClickHouseTable table) throws SQLException { + List keys = table.getColumns().stream() + .filter(c -> isScalarGroupKey(c.getType().getType()) && isReadableByStar(c)) + .collect(Collectors.toList()); + if (keys.isEmpty()) { + throw new IgnoreMeException(); + } + String key = quote(Randomly.fromList(keys).getName()); + List ints = table.getColumns().stream() + .filter(c -> isExactInteger(c.getType().getType()) && isReadableByStar(c)) + .collect(Collectors.toList()); + String sumArg = ints.isEmpty() ? "0" : quote(Randomly.fromList(ints).getName()); + + String classic = "SELECT toString(tuple(" + key + ", count(), sum(" + sumArg + "))) FROM " + from + " WHERE " + + predicate + " GROUP BY " + key; + String pipe = "FROM " + from + " |> WHERE " + predicate + " |> AGGREGATE count() AS pipe_c, sum(" + sumArg + + ") AS pipe_s GROUP BY " + key + " |> SELECT toString(tuple(" + key + ", pipe_c, pipe_s))"; + compareMultisets(classic, pipe); + } + + private void checkOrderLimit(String from, String predicate, ClickHouseTable table) throws SQLException { + String projection = rowProjection(table); + long limit = 1 + Randomly.getNotCachedInteger(0, 20); + String classic = "SELECT " + projection + " AS pipe_o FROM " + from + " WHERE " + predicate + + " ORDER BY pipe_o ASC LIMIT " + limit; + String pipe = "FROM " + from + " |> WHERE " + predicate + " |> SELECT " + projection + + " AS pipe_o |> ORDER BY pipe_o ASC |> LIMIT " + limit; + + log(classic); + List classicRows = ComparatorHelper.getResultSetFirstColumnAsString(classic, errors, state); + log(pipe); + List pipeRows = ComparatorHelper.getResultSetFirstColumnAsString(pipe, errors, state); + if (!classicRows.equals(pipeRows)) { + throw new AssertionError(String.format( + "pipe-operator ORDER BY / LIMIT mismatch: the classic query returned %d rows and its pipe-syntax " + + "rendering returned %d under an identical total order.%n classic: %s%n pipe: %s%n" + + " classic rows: %s%n pipe rows: %s", + classicRows.size(), pipeRows.size(), classic, pipe, classicRows, pipeRows)); + } + } + + private void compareMultisets(String classic, String pipe) throws SQLException { + log(classic); + List classicRows = ComparatorHelper.getResultSetFirstColumnAsString(classic, errors, state); + log(pipe); + List pipeRows = ComparatorHelper.getResultSetFirstColumnAsString(pipe, errors, state); + ComparatorHelper.assumeResultSetsAreEqual(classicRows, pipeRows, classic, List.of(pipe), state, + ComparatorHelper.ComparisonMode.MULTISET); + } + + private static String rowProjection(ClickHouseTable table) { + List projectable = table.getColumns().stream() + .filter(ClickHousePipeEquivalenceOracle::isReadableByStar).collect(Collectors.toList()); + if (projectable.isEmpty()) { + throw new IgnoreMeException(); + } + return "toString(tuple(" + + projectable.stream().map(c -> quote(c.getName())).collect(Collectors.joining(", ")) + "))"; + } + + private static boolean isReadableByStar(ClickHouseColumn column) { + return !column.isAlias() && !column.isMaterialized(); + } + + private static boolean isScalarGroupKey(ClickHouseDataType t) { + return isExactInteger(t) || t == ClickHouseDataType.String || t == ClickHouseDataType.FixedString + || t == ClickHouseDataType.Date || t == ClickHouseDataType.Date32 || t == ClickHouseDataType.DateTime + || t == ClickHouseDataType.DateTime64 || t == ClickHouseDataType.UUID; + } + + private static boolean isExactInteger(ClickHouseDataType t) { + switch (t) { + case Int8: + case Int16: + case Int32: + case Int64: + case Int128: + case Int256: + case UInt8: + case UInt16: + case UInt32: + case UInt64: + case UInt128: + case UInt256: + return true; + default: + return false; + } + } + + private static String quote(String identifier) { + return "`" + identifier.replace("`", "``") + "`"; + } + + private void log(String sql) { + if (state.getOptions().logEachSelect()) { + state.getLogger().writeCurrent(sql); + state.getState().logStatement(sql); + } + } +} diff --git a/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java b/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java index cf2503d4f..a1586c927 100644 --- a/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java +++ b/src/sqlancer/clickhouse/oracle/qcc/ClickHouseQueryConditionCacheOracle.java @@ -60,12 +60,12 @@ public void check() throws SQLException { baseline.setFetchColumns(List.of(columns.get(0))); baseline.setWhereClause(gen.generatePredicate()); String baselineBody = ClickHouseVisitor.asString(baseline); + if (Randomly.getBoolean()) { + baselineBody += " ORDER BY " + ClickHouseVisitor.asString(columns.get(0)) + " ASC LIMIT " + + (1 + Randomly.getNotCachedInteger(0, 20)); + } - String dropCache = "SYSTEM DROP QUERY CONDITION CACHE"; - - try { - new SQLQueryAdapter(dropCache, errors, false).execute(state); - } catch (Exception e) { + if (!dropCache()) { throw new IgnoreMeException(); } @@ -82,10 +82,57 @@ public void check() throws SQLException { } } - String cachedQuery = baselineBody + " SETTINGS use_query_condition_cache = 1"; + String cachedQuery = baselineBody + " SETTINGS use_query_condition_cache = 1, " + + "use_query_condition_cache_for_top_k = 1"; List cachedResult = ComparatorHelper.getResultSetFirstColumnAsString(cachedQuery, errors, state); - ComparatorHelper.assumeResultSetsAreEqual(truthResult, cachedResult, truthQuery, List.of(cachedQuery), state); + if (sameMultiset(truthResult, cachedResult)) { + return; + } + + if (!dropCache()) { + throw new IgnoreMeException(); + } + List truthAfterDrop = ComparatorHelper.getResultSetFirstColumnAsString(truthQuery, errors, state); + List cachedAfterDrop = ComparatorHelper.getResultSetFirstColumnAsString(cachedQuery, errors, state); + if (!sameMultiset(truthAfterDrop, cachedAfterDrop) || !sameMultiset(truthAfterDrop, truthResult)) { + + throw new IgnoreMeException(); + } + + throw new AssertionError(String.format( + "query-condition-cache poisoning: after the trigger queries ran, the same read returned %d rows with " + + "the cache on and %d rows with the cache off, and the divergence disappeared after SYSTEM " + + "DROP QUERY CONDITION CACHE -- so the cache, not the data, produced the wrong answer.%n" + + " cache off: %s%n cache on: %s%n triggers: %s%n cache-off rows: %s%n" + + " cache-on rows: %s", + truthResult.size(), cachedResult.size(), truthQuery, cachedQuery, triggerQueries, truthResult, + cachedResult)); + } + + private static boolean sameMultiset(List a, List b) { + if (a.size() != b.size()) { + return false; + } + List left = new ArrayList<>(a.size()); + List right = new ArrayList<>(b.size()); + for (String s : a) { + left.add(s == null ? "\\N" : s); + } + for (String s : b) { + right.add(s == null ? "\\N" : s); + } + left.sort(String::compareTo); + right.sort(String::compareTo); + return left.equals(right); + } + + private boolean dropCache() { + try { + return new SQLQueryAdapter("SYSTEM DROP QUERY CONDITION CACHE", errors, false).execute(state); + } catch (Exception e) { + return false; + } } private List buildTriggerQueries(ClickHouseTable table, List columns, @@ -111,6 +158,21 @@ private List buildTriggerQueries(ClickHouseTable table, List TRIGGERS_PER_CHECK) { triggers = triggers.subList(0, TRIGGERS_PER_CHECK); } diff --git a/src/sqlancer/clickhouse/oracle/readorder/ClickHouseReadInOrderToggleOracle.java b/src/sqlancer/clickhouse/oracle/readorder/ClickHouseReadInOrderToggleOracle.java index 5cc70df4b..c9fd14de0 100644 --- a/src/sqlancer/clickhouse/oracle/readorder/ClickHouseReadInOrderToggleOracle.java +++ b/src/sqlancer/clickhouse/oracle/readorder/ClickHouseReadInOrderToggleOracle.java @@ -62,7 +62,10 @@ private void checkOrderArm(ClickHouseTable table) throws SQLException { String projection = "toString(tuple(" + orderableCols.stream().map(c -> ref(c.getName())).collect(Collectors.joining(", ")) + "))"; - String orderBy = orderableCols.stream().map(c -> ref(c.getName()) + " ASC").collect(Collectors.joining(", ")); + boolean mixedDirections = Randomly.getBoolean(); + String orderBy = orderableCols.stream() + .map(c -> ref(c.getName()) + (mixedDirections && Randomly.getBoolean() ? " DESC" : " ASC")) + .collect(Collectors.joining(", ")); int limit = Randomly.fromOptions(1, 5, 20); String base = "SELECT " + projection + " FROM " + table.getName() + " ORDER BY " + orderBy + " LIMIT " + limit; @@ -86,16 +89,28 @@ private void checkGroupByArm(ClickHouseTable table) throws SQLException { if (keyCols.isEmpty()) { throw new IgnoreMeException(); } - ClickHouseColumn key = keyCols.get((int) Randomly.getNotCachedInteger(0, keyCols.size())); + List groupKeys = new java.util.ArrayList<>(); + groupKeys.add(keyCols.get((int) Randomly.getNotCachedInteger(0, keyCols.size()))); + if (keyCols.size() > 1 && Randomly.getBoolean()) { + ClickHouseColumn second = keyCols.get((int) Randomly.getNotCachedInteger(0, keyCols.size())); + if (!second.getName().equals(groupKeys.get(0).getName())) { + groupKeys.add(second); + } + } List intCols = table.getColumns().stream() .filter(c -> isExactInteger(c.getType().getType())).collect(Collectors.toList()); String sumArg = intCols.isEmpty() ? "0" : ref(intCols.get((int) Randomly.getNotCachedInteger(0, intCols.size())).getName()); - String keyRef = ref(key.getName()); - String projection = "toString(tuple(" + keyRef + ", count(), sum(" + sumArg + ")))"; - String base = "SELECT " + projection + " FROM " + table.getName() + " GROUP BY " + keyRef; + String keyRefs = groupKeys.stream().map(c -> ref(c.getName())).collect(Collectors.joining(", ")); + String projection = "toString(tuple(" + keyRefs + ", count(), sum(" + sumArg + ")))"; + String base = "SELECT " + projection + " FROM " + table.getName() + " GROUP BY " + keyRefs; + if (Randomly.getBoolean()) { + base += " ORDER BY " + groupKeys.stream() + .map(c -> ref(c.getName()) + (Randomly.getBoolean() ? " DESC" : " ASC")) + .collect(Collectors.joining(", ")); + } String on = base + ARM_ON; String off = base + ARM_OFF; diff --git a/src/sqlancer/clickhouse/oracle/settingflip/ClickHouseSettingFlipOracle.java b/src/sqlancer/clickhouse/oracle/settingflip/ClickHouseSettingFlipOracle.java index 6e8313f50..d770f8ccf 100644 --- a/src/sqlancer/clickhouse/oracle/settingflip/ClickHouseSettingFlipOracle.java +++ b/src/sqlancer/clickhouse/oracle/settingflip/ClickHouseSettingFlipOracle.java @@ -101,8 +101,15 @@ public void check() throws SQLException { groupBy = " GROUP BY " + keyName; } + if (state.getClickHouseOptions().comparisonChainEmission && Randomly.getBoolean()) { + ClickHouseExpression chain = gen.generateComparisonChain(columns); + if (chain != null) { + whereClause = " WHERE " + ClickHouseToStringVisitor.asString(chain); + } + } + String base = "SELECT " + projection + " FROM " + table.getName() + whereClause + groupBy; - String[] flip = NEUTRAL_SETTINGS[(int) Randomly.getNotCachedInteger(0, NEUTRAL_SETTINGS.length)]; + String[] flip = pickFlip(whereClause); String queryA = base + " SETTINGS " + flip[0] + " = " + flip[1]; String queryB = base + " SETTINGS " + flip[0] + " = " + flip[2]; @@ -111,6 +118,17 @@ public void check() throws SQLException { ComparatorHelper.assumeResultSetsAreEqual(rowsA, rowsB, queryA, List.of(queryB), state); } + private static final String[][] CHAIN_REWRITE_SETTINGS = { { "optimize_or_like_chain", "1", "0" }, + { "optimize_and_compare_chain", "1", "0" }, { "convert_query_to_cnf", "1", "0" } }; + + private String[] pickFlip(String whereClause) { + if (!whereClause.isEmpty() && state.getClickHouseOptions().comparisonChainEmission + && Randomly.getBoolean()) { + return CHAIN_REWRITE_SETTINGS[(int) Randomly.getNotCachedInteger(0, CHAIN_REWRITE_SETTINGS.length)]; + } + return NEUTRAL_SETTINGS[(int) Randomly.getNotCachedInteger(0, NEUTRAL_SETTINGS.length)]; + } + private static boolean isScalarGroupKey(ClickHouseDataType t) { return isExactInteger(t) || t == ClickHouseDataType.String || t == ClickHouseDataType.FixedString || t == ClickHouseDataType.Date || t == ClickHouseDataType.Date32 || t == ClickHouseDataType.DateTime diff --git a/src/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracle.java b/src/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracle.java index aa81be884..2b46cd37b 100644 --- a/src/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracle.java +++ b/src/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracle.java @@ -28,13 +28,16 @@ public class ClickHouseStatsToggleOracle implements TestOracle STATISTICS_KINDS = List.of("tdigest", "uniq", "countmin", "minmax"); + static final List STATISTICS_KINDS = List.of("tdigest", "uniq", "countmin", "minmax", "uniq_v2", "basic"); private static final Map MATERIALIZED_BY_KIND = Map.of("tdigest", new AtomicLong(), "uniq", - new AtomicLong(), "countmin", new AtomicLong(), "minmax", new AtomicLong()); + new AtomicLong(), "countmin", new AtomicLong(), "minmax", new AtomicLong(), "uniq_v2", new AtomicLong(), + "basic", new AtomicLong()); - static final String ARM_STATS_ON = "SETTINGS use_statistics = 1, allow_statistics_optimize = 1"; - static final String ARM_STATS_OFF = "SETTINGS use_statistics = 0, allow_statistics_optimize = 0"; + static final String ARM_STATS_ON = "SETTINGS use_statistics = 1, allow_statistics_optimize = 1, " + + "use_statistics_for_part_pruning = 1"; + static final String ARM_STATS_OFF = "SETTINGS use_statistics = 0, allow_statistics_optimize = 0, " + + "use_statistics_for_part_pruning = 0"; static final String PIN_JOIN_ORDER = ", query_plan_optimize_join_order_limit = 0"; @@ -49,6 +52,7 @@ public ClickHouseStatsToggleOracle(ClickHouseGlobalState state) { for (ExpectedErrors e : List.of(readErrors, statsDdlErrors)) { ClickHouseErrors.addSessionSettingsErrors(e); + ClickHouseErrors.addExpectedExpressionErrors(e); e.add("UNKNOWN_TABLE"); e.add("Unknown table expression identifier"); @@ -74,6 +78,7 @@ public ClickHouseStatsToggleOracle(ClickHouseGlobalState state) { statsDdlErrors.add("ILLEGAL_STATISTICS"); statsDdlErrors.add("already contains statistics"); + statsDdlErrors.add("because it's affected by mutation with ID"); statsDdlErrors.add("Exception happened during execution of mutation"); statsDdlErrors.add("UNFINISHED"); statsDdlErrors.add("contains a duplicate expression"); @@ -231,8 +236,18 @@ static List multisetDiff(List statsOnRows, List statsOff return diff; } + static String renderAutoStatisticsTypes() { + List pool = new ArrayList<>(STATISTICS_KINDS); + java.util.Collections.shuffle(pool, new java.util.Random(Randomly.getNotCachedInteger(0, Integer.MAX_VALUE))); + int n = (int) Randomly.getNotCachedInteger(0, 4); + return String.join(", ", pool.subList(0, Math.min(n, pool.size()))); + } + static List renderStalenessSetup(String table, long rows, String kindK, String kindV) { - return List.of("CREATE TABLE " + table + " (k Int32, v Int64) ENGINE = MergeTree ORDER BY k", + return List.of( + "CREATE TABLE " + table + " (k Int32, v Int64) ENGINE = MergeTree ORDER BY k SETTINGS " + + "auto_statistics_types = '" + renderAutoStatisticsTypes() + "', " + + "materialize_statistics_on_merge = " + (Randomly.getBoolean() ? 1 : 0), "INSERT INTO " + table + " SELECT toInt32(if(number % 4 = 3, number, number % 3)), " + "toInt64(number % 11) FROM numbers(" + rows + ")", "ALTER TABLE " + table + " ADD STATISTICS IF NOT EXISTS k TYPE " + kindK, diff --git a/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexDirectReadOracle.java b/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexDirectReadOracle.java index 4d611b939..5efea2f50 100644 --- a/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexDirectReadOracle.java +++ b/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexDirectReadOracle.java @@ -29,6 +29,7 @@ enum Scenario { NGRAMS("text(tokenizer = ngrams(3))"), SPARSEGRAMS("text(tokenizer = sparseGrams(3, 5))"), SPLIT_BY_STRING("text(tokenizer = splitByString([' ']))"), + ICU("text(tokenizer = icu('en'))"), PREPROCESSOR_LOWER("text(tokenizer = 'splitByNonAlpha', preprocessor = lower(s))"); private final String indexType; @@ -67,6 +68,9 @@ public void check() throws SQLException { Randomly r = state.getRandomly(); Scenario scenario = Scenario.values()[(int) Randomly.getNotCachedInteger(0, Scenario.values().length)]; + if (scenario == Scenario.ICU && !state.getClickHouseOptions().textIndexSecondWave) { + scenario = Scenario.SPLIT_CONTROL; + } String create = "CREATE TABLE " + table + " (k UInt32, s String, INDEX idx (s) TYPE " + scenario.indexType + " GRANULARITY 1) ENGINE = MergeTree ORDER BY k"; @@ -150,6 +154,7 @@ private static String pickPredicate(Randomly r, Scenario scenario, List return "hasToken(s, '" + esc(fragment) + "')"; } case SPLIT_BY_STRING: + case ICU: return "hasToken(s, '" + esc(vocab.get(r.getInteger(0, vocab.size()))) + "')"; case PREPROCESSOR_LOWER: return "hasToken(s, '" + esc(vocab.get(r.getInteger(0, vocab.size())).toLowerCase(Locale.ROOT)) + "')"; diff --git a/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexLifecycleOracle.java b/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexLifecycleOracle.java index ebe5ff14a..2a3bc2d2e 100644 --- a/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexLifecycleOracle.java +++ b/src/sqlancer/clickhouse/oracle/textindex/ClickHouseTextIndexLifecycleOracle.java @@ -52,12 +52,23 @@ public void check() throws SQLException { String tableB = state.getDatabaseName() + ".txtlc_" + id + "_b"; Randomly r = state.getRandomly(); + boolean secondWave = state.getClickHouseOptions().textIndexSecondWave; boolean splitByNonAlpha = r.getInteger(0, 4) != 0; - String indexType = splitByNonAlpha ? "text(tokenizer = 'splitByNonAlpha')" : "text(tokenizer = ngrams(3))"; + String indexType; + if (splitByNonAlpha) { + indexType = "text(tokenizer = 'splitByNonAlpha')"; + } else if (secondWave && Randomly.getBoolean()) { + indexType = "text(tokenizer = icu('" + Randomly.fromOptions("en", "de", "fr", "ja") + "'))"; + } else { + indexType = "text(tokenizer = ngrams(3))"; + } + boolean phraseSearch = secondWave && splitByNonAlpha; + String tableSettings = renderTableSettings(secondWave, phraseSearch); String createA = "CREATE TABLE " + tableA + " (k UInt32, s String, INDEX " + INDEX_NAME + " (s) TYPE " - + indexType + " GRANULARITY 1) ENGINE = MergeTree ORDER BY k"; - String createB = "CREATE TABLE " + tableB + " (k UInt32, s String) ENGINE = MergeTree ORDER BY k"; + + indexType + " GRANULARITY 1) ENGINE = MergeTree ORDER BY k" + tableSettings; + String createB = "CREATE TABLE " + tableB + " (k UInt32, s String) ENGINE = MergeTree ORDER BY k" + + tableSettings; List corpus = new ArrayList<>(); try { @@ -101,11 +112,17 @@ public void check() throws SQLException { throw new IgnoreMeException(); } - for (String predicate : predicateBattery(r, splitByNonAlpha)) { + for (String predicate : predicateBattery(r, splitByNonAlpha, phraseSearch)) { List keysA = keys(tableA, predicate, ""); List keysB = keys(tableB, predicate, ""); List keysScan = keys(tableB, predicate, " SETTINGS use_skip_indexes = 0"); + if (secondWave) { + assertTrivialCountAgrees(tableA, predicate, keysScan.size(), indexType); + } + if (predicate.startsWith("hasPhrase(")) { + assertPhraseGroundTruth(corpus, predicate, keysA, indexType); + } if (!keysA.equals(keysScan)) { throw new AssertionError(String.format( "text-index lifecycle mismatch (born-with-index vs scan): predicate %s. A keys %s vs " @@ -125,7 +142,22 @@ public void check() throws SQLException { } } - private static List predicateBattery(Randomly r, boolean splitByNonAlpha) { + private static String renderTableSettings(boolean secondWave, boolean phraseSearch) { + List settings = new ArrayList<>(); + if (phraseSearch) { + settings.add("allow_experimental_text_index_phrase_search = 1"); + } + if (secondWave && Randomly.getBoolean()) { + settings.add("text_index_dictionary_block_size = " + Randomly.fromOptions(64, 128, 512, 4096)); + settings.add("text_index_dictionary_block_frontcoding_compression = " + Randomly.fromOptions(0, 1)); + } + if (secondWave && Randomly.getBoolean()) { + settings.add("text_index_posting_list_block_size = " + Randomly.fromOptions(4096, 65536, 1048576)); + } + return settings.isEmpty() ? "" : " SETTINGS " + String.join(", ", settings); + } + + private static List predicateBattery(Randomly r, boolean splitByNonAlpha, boolean phraseSearch) { List vocab = ClickHouseTextIndexLikeOracle.TOKEN_VOCABULARY; String w1 = ClickHouseTextIndexLikeOracle.escapeStringLiteral(vocab.get(r.getInteger(0, vocab.size()))); String w2 = ClickHouseTextIndexLikeOracle.escapeStringLiteral(vocab.get(r.getInteger(0, vocab.size()))); @@ -136,9 +168,77 @@ private static List predicateBattery(Randomly r, boolean splitByNonAlpha battery.add("hasAllTokens(s, '" + w1 + " " + w2 + "')"); battery.add("hasAnyTokens(s, '" + w1 + " " + w2 + "')"); } + if (phraseSearch) { + battery.add("hasPhrase(s, '" + w1 + " " + w2 + "')"); + } return battery; } + private void assertTrivialCountAgrees(String table, String predicate, int expected, String indexType) + throws SQLException { + String optimized = "SELECT toString(count()) FROM " + table + " WHERE " + predicate + + " SETTINGS query_plan_optimize_count_from_text_index = 1"; + String plain = "SELECT toString(count()) FROM " + table + " WHERE " + predicate + + " SETTINGS query_plan_optimize_count_from_text_index = 0, use_skip_indexes = 0"; + logStmt(optimized); + List optimizedRows = ComparatorHelper.getResultSetFirstColumnAsString(optimized, readErrors, state); + logStmt(plain); + List plainRows = ComparatorHelper.getResultSetFirstColumnAsString(plain, readErrors, state); + if (optimizedRows.size() != 1 || plainRows.size() != 1) { + throw new IgnoreMeException(); + } + String scanned = String.valueOf(expected); + if (!optimizedRows.get(0).equals(plainRows.get(0)) || !optimizedRows.get(0).equals(scanned)) { + throw new AssertionError(String.format( + "trivial-count-from-text-index mismatch: count() answered from the text index is %s, the " + + "full-scan count is %s, and the row list of the same predicate holds %s keys. " + + "predicate %s, index type %s", + optimizedRows.get(0), plainRows.get(0), scanned, predicate, indexType)); + } + } + + private static void assertPhraseGroundTruth(List corpus, String predicate, List keys, + String indexType) { + int open = predicate.indexOf('\''); + int close = predicate.lastIndexOf('\''); + if (open < 0 || close <= open) { + return; + } + String[] phrase = predicate.substring(open + 1, close).split(" "); + List expected = new ArrayList<>(); + for (int key = 0; key < corpus.size(); key++) { + if (containsPhrase(corpus.get(key).split(" "), phrase)) { + expected.add(String.valueOf(key)); + } + } + if (!expected.equals(keys)) { + throw new AssertionError(String.format( + "hasPhrase ground-truth mismatch: the corpus is whitespace-separated tokens, so hasPhrase is true " + + "exactly when the needle's tokens occur consecutively. Java expects %d keys, the query " + + "returned %d. predicate %s, index type %s%n expected: %s%n actual: %s", + expected.size(), keys.size(), predicate, indexType, truncate(expected), truncate(keys))); + } + } + + private static boolean containsPhrase(String[] tokens, String[] phrase) { + if (phrase.length == 0 || phrase.length > tokens.length) { + return false; + } + for (int start = 0; start + phrase.length <= tokens.length; start++) { + boolean match = true; + for (int i = 0; i < phrase.length; i++) { + if (!tokens[start + i].equals(phrase[i])) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; + } + private List keys(String table, String predicate, String suffix) throws SQLException { return ComparatorHelper.getResultSetFirstColumnAsString( "SELECT toString(k) FROM " + table + " WHERE " + predicate + " ORDER BY k" + suffix, readErrors, state); diff --git a/src/sqlancer/clickhouse/oracle/window/ClickHouseWindowFrameGroundTruthOracle.java b/src/sqlancer/clickhouse/oracle/window/ClickHouseWindowFrameGroundTruthOracle.java index 28fc6e3ee..460ee5028 100644 --- a/src/sqlancer/clickhouse/oracle/window/ClickHouseWindowFrameGroundTruthOracle.java +++ b/src/sqlancer/clickhouse/oracle/window/ClickHouseWindowFrameGroundTruthOracle.java @@ -111,9 +111,214 @@ public void check() throws SQLException { Probe probe = probes.get(i); assertProbe(table, probe, expected(probe, model)); } + if (state.getClickHouseOptions().groupsWindowFrameEmission) { + assertGroupsEqualsRowsOnUniqueKey(table); + } } finally { dropQuietly(table); } + if (state.getClickHouseOptions().groupsWindowFrameEmission) { + checkGroupsFrame(); + } + } + + private void assertGroupsEqualsRowsOnUniqueKey(String table) throws SQLException { + long offset = 1 + state.getRandomly().getInteger(0, 3); + String query = "SELECT toString(tuple(g, w)) FROM (SELECT sum(v) OVER (PARTITION BY p ORDER BY ord GROUPS " + + "BETWEEN " + offset + " PRECEDING AND CURRENT ROW) AS g, sum(v) OVER (PARTITION BY p ORDER BY ord " + + "ROWS BETWEEN " + offset + " PRECEDING AND CURRENT ROW) AS w FROM " + table + ") WHERE g != w"; + logStmt(query); + List violations = ComparatorHelper.getResultSetFirstColumnAsString(query, errors, state); + if (!violations.isEmpty()) { + throw new AssertionError(String.format( + "GROUPS frame differs from the equivalent ROWS frame on a fixture whose window ORDER BY key is " + + "unique per partition, so every peer group holds exactly one row and the two frames must " + + "coincide. %d rows disagree (GROUPS, ROWS): %s%n Q: %s", + violations.size(), truncate(violations), query)); + } + } + + private void checkGroupsFrame() throws SQLException { + long id = CTR.incrementAndGet(); + Randomly r = state.getRandomly(); + String table = state.getDatabaseName() + ".wing_" + id; + String create = "CREATE TABLE " + table + + " (p UInt32, ord Int64, rid Int64, v Int64) ENGINE = MergeTree ORDER BY (p, ord, rid)"; + + int partitions = 2 + r.getInteger(0, 2); + List>> model = new ArrayList<>(); + + try { + logStmt(create); + if (!new SQLQueryAdapter(create, errors, true).execute(state)) { + throw new IgnoreMeException(); + } + + StringBuilder sb = new StringBuilder("INSERT INTO ").append(table).append(" (p, ord, rid, v) VALUES "); + boolean firstRow = true; + long rid = 0; + for (int p = 0; p < partitions; p++) { + int groups = 3 + r.getInteger(0, 5); + List> partitionGroups = new ArrayList<>(); + long ord = 0; + for (int g = 0; g < groups; g++) { + ord += 1 + r.getInteger(0, 5); + int peers = 1 + r.getInteger(0, 3); + List groupValues = new ArrayList<>(); + for (int i = 0; i < peers; i++) { + long v = r.getInteger(-50, 51); + groupValues.add(v); + if (!firstRow) { + sb.append(", "); + } + firstRow = false; + sb.append('(').append(p).append(", ").append(ord).append(", ").append(rid++).append(", ") + .append(v).append(')'); + } + partitionGroups.add(groupValues); + } + model.add(partitionGroups); + } + if (firstRow) { + throw new IgnoreMeException(); + } + logStmt(sb.toString()); + if (!new SQLQueryAdapter(sb.toString(), errors, true).execute(state)) { + throw new IgnoreMeException(); + } + + for (GroupsProbe probe : GroupsProbe.values()) { + assertGroupsProbe(table, probe, expectedGroups(probe, model)); + } + assertConstantKeyWholePartition(); + } finally { + dropQuietly(table); + } + } + + private void assertConstantKeyWholePartition() throws SQLException { + String query = "SELECT toString(tuple(g, whole)) FROM (SELECT sum(v) OVER (PARTITION BY p ORDER BY ord GROUPS " + + "BETWEEN 0 PRECEDING AND 0 FOLLOWING) AS g, sum(v) OVER (PARTITION BY p) AS whole FROM " + + "(SELECT number % 3 AS p, 7 AS ord, toInt64(number) AS v FROM numbers(30))) WHERE g != whole"; + logStmt(query); + List violations = ComparatorHelper.getResultSetFirstColumnAsString(query, errors, state); + if (!violations.isEmpty()) { + throw new AssertionError(String.format( + "GROUPS BETWEEN 0 PRECEDING AND 0 FOLLOWING over a constant window ORDER BY key must cover the " + + "whole partition (every row is one peer group), but %d rows disagree with the " + + "whole-partition aggregate (GROUPS, whole): %s%n Q: %s", + violations.size(), truncate(violations), query)); + } + } + + private enum GroupsProbe { + PREFIX("sum(v) OVER (PARTITION BY p ORDER BY ord GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)"), + NEIGHBOR("sum(v) OVER (PARTITION BY p ORDER BY ord GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING)"), + TRAILING_COUNT("count() OVER (PARTITION BY p ORDER BY ord GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW)"), + CURRENT_GROUP_MIN("min(v) OVER (PARTITION BY p ORDER BY ord GROUPS BETWEEN CURRENT ROW AND CURRENT ROW)"), + SUFFIX_MAX("max(v) OVER (PARTITION BY p ORDER BY ord GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)"); + + private final String windowExpr; + + GroupsProbe(String windowExpr) { + this.windowExpr = windowExpr; + } + } + + private static List expectedGroups(GroupsProbe probe, List>> model) { + List result = new ArrayList<>(); + for (List> partition : model) { + int groups = partition.size(); + for (int g = 0; g < groups; g++) { + int lo; + int hi; + switch (probe) { + case PREFIX: + lo = 0; + hi = g; + break; + case NEIGHBOR: + lo = Math.max(0, g - 1); + hi = Math.min(groups - 1, g + 1); + break; + case TRAILING_COUNT: + lo = Math.max(0, g - 2); + hi = g; + break; + case CURRENT_GROUP_MIN: + lo = g; + hi = g; + break; + case SUFFIX_MAX: + lo = g; + hi = groups - 1; + break; + default: + throw new AssertionError(probe.name()); + } + String value = aggregateOverGroups(probe, partition, lo, hi); + for (int i = 0; i < partition.get(g).size(); i++) { + result.add(value); + } + } + } + return result; + } + + private static String aggregateOverGroups(GroupsProbe probe, List> partition, int lo, int hi) { + long sum = 0; + long count = 0; + long min = Long.MAX_VALUE; + long max = Long.MIN_VALUE; + for (int g = lo; g <= hi; g++) { + for (Long v : partition.get(g)) { + sum += v; + count++; + min = Math.min(min, v); + max = Math.max(max, v); + } + } + switch (probe) { + case PREFIX: + case NEIGHBOR: + return String.valueOf(sum); + case TRAILING_COUNT: + return String.valueOf(count); + case CURRENT_GROUP_MIN: + return String.valueOf(min); + case SUFFIX_MAX: + return String.valueOf(max); + default: + throw new AssertionError(probe.name()); + } + } + + private void assertGroupsProbe(String table, GroupsProbe probe, List expected) throws SQLException { + String query = "SELECT toString(" + probe.windowExpr + ") FROM " + table + " ORDER BY p, ord, rid"; + logStmt(query); + List actual = ComparatorHelper.getResultSetFirstColumnAsString(query, errors, state); + if (actual.size() != expected.size()) { + throw new AssertionError(String.format( + "GROUPS-frame ground-truth row-count mismatch (%s): Java expects %d rows but query returned %d. " + + "Q: %s", + probe.name(), expected.size(), actual.size(), query)); + } + for (int i = 0; i < expected.size(); i++) { + if (!nullSafeEquals(expected.get(i), actual.get(i))) { + throw new AssertionError(String.format( + "GROUPS-frame ground-truth mismatch (%s) at global row %d: the frame spans peer groups (rows " + + "tied on the window ORDER BY key), Java expects %s but query returned %s. Q: %s", + probe.name(), i, expected.get(i), actual.get(i), query)); + } + } + } + + private static String truncate(List rows) { + int limit = 20; + if (rows.size() <= limit) { + return rows.toString(); + } + return rows.subList(0, limit) + "... (" + rows.size() + " total)"; } private static List expected(Probe probe, List> model) { diff --git a/test/sqlancer/clickhouse/ast/ClickHouseSelectArrayJoinTest.java b/test/sqlancer/clickhouse/ast/ClickHouseSelectArrayJoinTest.java index 702f93ecc..c2994eb0d 100644 --- a/test/sqlancer/clickhouse/ast/ClickHouseSelectArrayJoinTest.java +++ b/test/sqlancer/clickhouse/ast/ClickHouseSelectArrayJoinTest.java @@ -106,8 +106,8 @@ void arrayJoinPositionedBeforeJoinClauses() { String rendered = ClickHouseVisitor.asString(select); int arrayJoinIdx = rendered.indexOf("ARRAY JOIN"); - int joinIdx = rendered.indexOf(" JOIN t2"); + int joinIdx = rendered.indexOf(", t2"); assertTrue(arrayJoinIdx >= 0 && joinIdx > arrayJoinIdx, - "ARRAY JOIN must come before regular JOIN, got: " + rendered); + "ARRAY JOIN must come before the comma-joined relation, got: " + rendered); } } diff --git a/test/sqlancer/clickhouse/ast/ClickHouseToStringVisitorTest.java b/test/sqlancer/clickhouse/ast/ClickHouseToStringVisitorTest.java index ba9628037..efb269620 100644 --- a/test/sqlancer/clickhouse/ast/ClickHouseToStringVisitorTest.java +++ b/test/sqlancer/clickhouse/ast/ClickHouseToStringVisitorTest.java @@ -208,7 +208,7 @@ void selectCrossJoinTest() { ClickHouseExpression.ClickHouseJoin.JoinType.CROSS); select.setJoinClauses(Arrays.asList(join)); String result = ClickHouseVisitor.asString(select); - String answer = "SELECT t1.a1, t2.a2, t1.b1, t2.b2 FROM t1 JOIN t2"; + String answer = "SELECT t1.a1, t2.a2, t1.b1, t2.b2 FROM t1, t2"; assertEquals(answer, result); } @@ -250,7 +250,7 @@ void selectCrossJoinAliasedTest() { ClickHouseExpression.ClickHouseJoin.JoinType.CROSS); select.setJoinClauses(Arrays.asList(join)); String result = ClickHouseVisitor.asString(select); - String answer = "SELECT left.a1, left.b1, right.a2, right.b2 FROM t1 AS left JOIN t2 AS right"; + String answer = "SELECT left.a1, left.b1, right.a2, right.b2 FROM t1 AS left, t2 AS right"; assertEquals(answer, result); } diff --git a/test/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracleTest.java b/test/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracleTest.java index ce27f3c2e..f537f46f1 100644 --- a/test/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracleTest.java +++ b/test/sqlancer/clickhouse/oracle/stats/ClickHouseStatsToggleOracleTest.java @@ -30,8 +30,10 @@ void differentialPairRendersExactSettingsSuffixes() { String select = "SELECT toString(tuple(`c0`)) FROM t0 WHERE (`c0` < 5)"; String[] pair = ClickHouseStatsToggleOracle.renderDifferentialPair(select, false); assertEquals(2, pair.length); - assertEquals(select + " SETTINGS use_statistics = 1, allow_statistics_optimize = 1", pair[0]); - assertEquals(select + " SETTINGS use_statistics = 0, allow_statistics_optimize = 0", pair[1]); + assertEquals(select + " SETTINGS use_statistics = 1, allow_statistics_optimize = 1, " + + "use_statistics_for_part_pruning = 1", pair[0]); + assertEquals(select + " SETTINGS use_statistics = 0, allow_statistics_optimize = 0, " + + "use_statistics_for_part_pruning = 0", pair[1]); } @Test @@ -39,9 +41,9 @@ void joinShapedDifferentialPinsJoinOrderOnBothArms() { String[] pair = ClickHouseStatsToggleOracle.renderDifferentialPair("SELECT 1", true); assertEquals("SELECT 1 SETTINGS use_statistics = 1, allow_statistics_optimize = 1, " - + "query_plan_optimize_join_order_limit = 0", pair[0]); + + "use_statistics_for_part_pruning = 1, query_plan_optimize_join_order_limit = 0", pair[0]); assertEquals("SELECT 1 SETTINGS use_statistics = 0, allow_statistics_optimize = 0, " - + "query_plan_optimize_join_order_limit = 0", pair[1]); + + "use_statistics_for_part_pruning = 0, query_plan_optimize_join_order_limit = 0", pair[1]); } @Test @@ -64,7 +66,10 @@ void stalenessSetupRendersEveryKind() { @Test void kindPoolMatchesTheStatisticsGenerators() { - assertEquals(List.of("tdigest", "uniq", "countmin", "minmax"), ClickHouseStatsToggleOracle.STATISTICS_KINDS); + assertEquals(List.of("tdigest", "uniq", "countmin", "minmax", "uniq_v2", "basic"), + ClickHouseStatsToggleOracle.STATISTICS_KINDS); + assertEquals(ClickHouseStatsToggleOracle.STATISTICS_KINDS, + sqlancer.clickhouse.gen.ClickHouseStatisticsGenerator.KINDS); } @Test @@ -77,7 +82,9 @@ void dropStatisticsCoversBothColumns() { void stalenessSequenceOrdersCreateInsertStatsMaterialize() { List seq = ClickHouseStatsToggleOracle.renderStalenessSetup("db.stats_1_t", 750, "tdigest", "minmax"); assertEquals(7, seq.size()); - assertEquals("CREATE TABLE db.stats_1_t (k Int32, v Int64) ENGINE = MergeTree ORDER BY k", seq.get(0)); + assertTrue(seq.get(0).startsWith("CREATE TABLE db.stats_1_t (k Int32, v Int64) ENGINE = MergeTree ORDER BY k " + + "SETTINGS auto_statistics_types = '"), seq.get(0)); + assertTrue(seq.get(0).contains("materialize_statistics_on_merge = "), seq.get(0)); assertEquals("INSERT INTO db.stats_1_t SELECT toInt32(if(number % 4 = 3, number, number % 3)), " + "toInt64(number % 11) FROM numbers(750)", seq.get(1)); assertEquals("ALTER TABLE db.stats_1_t ADD STATISTICS IF NOT EXISTS k TYPE tdigest", seq.get(2)); @@ -184,10 +191,10 @@ void assertMultisetsEqualPassesOnEqualMultisets() { } @Test - void kindRotationCoversEveryKindWithinFourIterations() { + void kindRotationCoversEveryKind() { Set seenK = new HashSet<>(); Set seenV = new HashSet<>(); - for (long id = 1; id <= 4; id++) { + for (long id = 1; id <= ClickHouseStatsToggleOracle.STATISTICS_KINDS.size(); id++) { seenK.add(ClickHouseStatsToggleOracle.kindForIteration(id, 0)); seenV.add(ClickHouseStatsToggleOracle.kindForIteration(id, 1)); }