Skip to content

Commit d1a7838

Browse files
craig[bot]mgartnerrafiss
committed
159001: opt: fix placeholder scan NULL semantics r=mgartner a=mgartner #### logictest: fix "query empty" directive This commit fixes a bug in logictest's `query empty` directive that caused a failing test, i.e., one that returns non-empty results, to result in a index-out-of-bounds panic instead of the clear assertion failure "expected empty result, found X rows". Release note: None #### opt: fix placeholder scan NULL semantics Fixes #158945 Release note (bug fix): A bug has been fixed which could cause incorrect results. The bug has existed since v21.2. From v21.2 up to v25.3, the bug only presented when all of the following were true: - The query was run with an explicit or implicit prepared statement. - The query had an equality filter on a placeholder and a UNIQUE column. - The column contained NULL values. - The placeholder was assigned to NULL during execution. In this case, the query could return rows in which the column's value is NULL, which violates SQL NULL-equality semantics. The correct result set should always be empty. Starting in v25.4, the requirements were loosened slightly. It was no longer necessary for the column to be UNIQUE. The bug could reproduce if the column was included in any index. #### opt: refactor placeholder scan execbuilding This is a purely mechanical change that moves a block of code in `buildPlaceholderScan`. An early exit now occurs earlier, avoiding unnecessary computation. Release note: None 159017: sql/sem/tree: prevent schema_locked bypass with comma syntax r=rafiss a=rafiss Previously, the legacy schema changer allowed bypassing the schema_locked protection by combining `SET (schema_locked=false)` with other schema-changing commands in the same ALTER TABLE statement. For example: ```sql ALTER TABLE t SET (schema_locked=false), DROP COLUMN n; ``` This would first unset schema_locked, then execute the DROP COLUMN on the now-unlocked table, effectively circumventing the protection. The fix ensures `IsSetOrResetSchemaLocked()` returns `false` when the ALTER TABLE statement contains any commands other than storage parameter changes. This prevents schema_locked changes from being combined with other schema changes in the same statement. Fixes: #150484 Release note (bug fix): Fixed a bug where the `schema_locked` table storage parameter could be bypassed by combining `SET (schema_locked=false)` with other schema changes in the same `ALTER TABLE` statement using comma syntax. Schema-locked tables now correctly reject such combined statements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marcus Gartner <[email protected]> Co-authored-by: Rafi Shamim <[email protected]>
3 parents 5e5f4dc + 0140d8b + bc3f228 commit d1a7838

File tree

6 files changed

+162
-22
lines changed

6 files changed

+162
-22
lines changed

pkg/sql/logictest/logic.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4010,6 +4010,13 @@ func (t *logicTest) finishExecQuery(query logicQuery, rowses []*gosql.Rows, exec
40104010
}
40114011

40124012
rowCount++
4013+
4014+
if query.empty {
4015+
// Skip column assertions if we are expecting an empty
4016+
// result.
4017+
continue
4018+
}
4019+
40134020
for i, v := range vals {
40144021
colT := query.colTypes[i]
40154022
// Ignore column - useful for non-deterministic output.

pkg/sql/logictest/testdata/logic_test/generic

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,71 @@ quality of service: regular
622622

623623
statement ok
624624
DEALLOCATE p
625+
626+
# Regression test for #158945. Placeholder scans should correctly handle NULL
627+
# placeholder values.
628+
statement ok
629+
CREATE TABLE t158945 (
630+
k INT PRIMARY KEY,
631+
a INT,
632+
b INT,
633+
UNIQUE (a),
634+
INDEX (b)
635+
)
636+
637+
statement ok
638+
INSERT INTO t158945 VALUES (1, NULL, NULL)
639+
640+
statement ok
641+
PREPARE p158945a AS SELECT k FROM t158945 WHERE a = $1
642+
643+
query empty
644+
EXECUTE p158945a(NULL)
645+
646+
query T
647+
EXPLAIN ANALYZE EXECUTE p158945a(NULL)
648+
----
649+
planning time: 10µs
650+
execution time: 100µs
651+
distribution: <hidden>
652+
vectorized: <hidden>
653+
plan type: generic, reused
654+
maximum memory usage: <hidden>
655+
DistSQL network usage: <hidden>
656+
regions: <hidden>
657+
isolation level: serializable
658+
priority: normal
659+
quality of service: regular
660+
·
661+
• norows
662+
sql nodes: <hidden>
663+
regions: <hidden>
664+
actual row count: 0
665+
execution time: 0µs
666+
667+
statement ok
668+
PREPARE p158945b AS SELECT k FROM t158945 WHERE a = $1
669+
670+
query empty
671+
EXECUTE p158945b(NULL)
672+
673+
query T
674+
EXPLAIN ANALYZE EXECUTE p158945b(NULL)
675+
----
676+
planning time: 10µs
677+
execution time: 100µs
678+
distribution: <hidden>
679+
vectorized: <hidden>
680+
plan type: generic, reused
681+
maximum memory usage: <hidden>
682+
DistSQL network usage: <hidden>
683+
regions: <hidden>
684+
isolation level: serializable
685+
priority: normal
686+
quality of service: regular
687+
·
688+
• norows
689+
sql nodes: <hidden>
690+
regions: <hidden>
691+
actual row count: 0
692+
execution time: 0µs

pkg/sql/logictest/testdata/logic_test/schema_locked

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,45 @@ t_147993 CREATE TABLE public.t_147993 (
218218
) WITH (schema_locked = true);
219219

220220
subtest end
221+
222+
# Regression test for #150484: schema_locked cannot be unset with the comma
223+
# syntax combined with other schema-changing operations. This prevents bypassing
224+
# the schema_locked protection by unsetting the lock and performing a schema
225+
# change in the same ALTER TABLE statement.
226+
subtest regression_150484
227+
228+
statement ok
229+
CREATE TABLE t_150484 (n INT, m INT, FAMILY (n, m)) WITH (schema_locked=true);
230+
231+
# Combining SET (schema_locked=false) with DROP COLUMN should fail.
232+
statement error pgcode 57000 pq: this schema change is disallowed because table "t_150484" is locked
233+
ALTER TABLE t_150484 SET (schema_locked=false), DROP COLUMN n;
234+
235+
# Combining RESET (schema_locked) with ADD COLUMN should fail.
236+
statement error pgcode 57000 pq: this schema change is disallowed because table "t_150484" is locked
237+
ALTER TABLE t_150484 RESET (schema_locked), ADD COLUMN k INT;
238+
239+
# Verify the table is still locked and unchanged.
240+
query TT
241+
SHOW CREATE TABLE t_150484
242+
----
243+
t_150484 CREATE TABLE public.t_150484 (
244+
n INT8 NULL,
245+
m INT8 NULL,
246+
rowid INT8 NOT VISIBLE NOT NULL DEFAULT unique_rowid(),
247+
CONSTRAINT t_150484_pkey PRIMARY KEY (rowid ASC),
248+
FAMILY fam_0_n_m_rowid (n, m, rowid)
249+
) WITH (schema_locked = true);
250+
251+
# Confirm that SET (schema_locked=false) alone still works.
252+
statement ok
253+
ALTER TABLE t_150484 SET (schema_locked=false);
254+
255+
# Now schema changes should work.
256+
statement ok
257+
ALTER TABLE t_150484 DROP COLUMN n;
258+
259+
statement ok
260+
DROP TABLE t_150484;
261+
262+
subtest end

pkg/sql/opt/exec/execbuilder/relational.go

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,33 @@ func (b *Builder) buildPlaceholderScan(
972972
return execPlan{}, colOrdMap{}, errors.AssertionFailedf("PlaceholderScan cannot have constraints")
973973
}
974974

975+
// Evaluate the scalar expressions.
976+
values := make([]tree.Datum, len(scan.Span))
977+
for i, expr := range scan.Span {
978+
// The expression is either a placeholder or a constant.
979+
var val tree.Datum
980+
if p, ok := expr.(*memo.PlaceholderExpr); ok {
981+
val, err = eval.Expr(b.ctx, b.evalCtx, p.Value)
982+
if err != nil {
983+
return execPlan{}, colOrdMap{}, err
984+
}
985+
} else {
986+
val = memo.ExtractConstDatum(expr)
987+
}
988+
if val == tree.DNull {
989+
// If any value is NULL, then build an empty values operator instead
990+
// of a scan. No row can satisfy the equality filter that was used
991+
// to build this placeholder scan, because of SQL NULL-equality
992+
// semantics.
993+
return b.buildValues(&memo.ValuesExpr{
994+
ValuesPrivate: memo.ValuesPrivate{
995+
Cols: scan.Cols.ToList(),
996+
},
997+
})
998+
}
999+
values[i] = val
1000+
}
1001+
9751002
md := b.mem.Metadata()
9761003
tab := md.Table(scan.Table)
9771004
idx := tab.Index(scan.Index)
@@ -988,20 +1015,6 @@ func (b *Builder) buildPlaceholderScan(
9881015
columns.Init(spanColumns)
9891016
keyCtx := constraint.MakeKeyContext(b.ctx, &columns, b.evalCtx)
9901017

991-
values := make([]tree.Datum, len(scan.Span))
992-
for i, expr := range scan.Span {
993-
// The expression is either a placeholder or a constant.
994-
if p, ok := expr.(*memo.PlaceholderExpr); ok {
995-
val, err := eval.Expr(b.ctx, b.evalCtx, p.Value)
996-
if err != nil {
997-
return execPlan{}, colOrdMap{}, err
998-
}
999-
values[i] = val
1000-
} else {
1001-
values[i] = memo.ExtractConstDatum(expr)
1002-
}
1003-
}
1004-
10051018
key := constraint.MakeCompositeKey(values...)
10061019
var span constraint.Span
10071020
span.Init(key, constraint.IncludeBoundary, key, constraint.IncludeBoundary)

pkg/sql/opt/ops/relational.opt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,11 @@ define ScanPrivate {
107107
}
108108

109109
# PlaceholderScan is a special variant of Scan. It scans exactly one span of a
110-
# non-inverted index, and the span has the same start and end key. The values
111-
# for the span key are child scalar operators which are either constants or
112-
# placeholders. This operator evaluates the placeholders at execbuild time.
110+
# non-inverted index, and the span has the same start and end key. The span key
111+
# is built at execbuild-time. Each scalar expression in the Span list
112+
# corresponds to a column in the index's key prefix, assuming SQL-equality
113+
# semantics (e.g., NULL is not equal to NULL). The scalar expressions are either
114+
# constants or placeholders. Placeholders are evaluated at execbuild-time.
113115
#
114116
# PlaceholderScan cannot have a Constraint or InvertedConstraint.
115117
[Relational]

pkg/sql/sem/tree/schema_helpers.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,34 @@ package tree
77

88
import "slices"
99

10-
// IsSetOrResetSchemaLocked returns true if `n` contains a command to
11-
// set/reset "schema_locked" storage parameter.
10+
// IsSetOrResetSchemaLocked returns true if `n` contains only commands to
11+
// set/reset storage parameters including "schema_locked", with no other
12+
// schema-changing commands. This prevents bypassing schema_locked protection
13+
// by combining SET (schema_locked=false) with other operations like DROP
14+
// COLUMN in the same ALTER TABLE statement.
1215
func IsSetOrResetSchemaLocked(n Statement) bool {
1316
alterStmt, ok := n.(*AlterTable)
1417
if !ok {
1518
return false
1619
}
20+
hasSchemaLockedChange := false
1721
for _, cmd := range alterStmt.Cmds {
1822
switch cmd := cmd.(type) {
1923
case *AlterTableSetStorageParams:
2024
if cmd.StorageParams.GetVal("schema_locked") != nil {
21-
return true
25+
hasSchemaLockedChange = true
2226
}
2327
case *AlterTableResetStorageParams:
2428
if slices.Contains(cmd.Params, "schema_locked") {
25-
return true
29+
hasSchemaLockedChange = true
2630
}
31+
default:
32+
// Any other command type means this is not a pure storage parameter
33+
// change, so we should not allow bypassing schema_locked checks.
34+
return false
2735
}
2836
}
29-
return false
37+
return hasSchemaLockedChange
3038
}
3139

3240
// IsAllowedLDRSchemaChange returns true if the schema change statement is

0 commit comments

Comments
 (0)