fix(#481): ALTER TABLE ADD COLUMN — new columns invisible to subsequent queries#495
fix(#481): ALTER TABLE ADD COLUMN — new columns invisible to subsequent queries#495lraigosov wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR extends the emulator’s DDL and query handling to better match BigQuery behavior, primarily by adding ALTER TABLE support and improving schema/metadata propagation.
Changes:
- Add
ALTER TABLEexecution path (parse AST, run raw SQLite DDL, update catalogs/metadata). - Improve result formatting for nested/REPEATED fields (notably nested TIMESTAMPs) and add related tests.
- Add helper to derive
TableReference.TableIdfromtable.Id, plus new tests and local dev ignores.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| server/handler.go | Implements ALTER TABLE handling, catalog updates, and terminalTableID helper. |
| internal/connection/manager.go | Adds raw SQLite execution helpers using reflect/unsafe. |
| internal/contentdata/repository.go | Switches repo mode calls used during table creation/data writes. |
| internal/types/types.go | Refactors formatting to handle nested/repeated cells (TIMESTAMP in RECORDs). |
| server/server_test.go | Adds integration test covering ALTER TABLE ADD COLUMN. |
| server/handler_tableid_test.go | Adds unit test for terminalTableID. |
| internal/types/types_test.go | Adds unit tests for formatCell edge cases and nested timestamp formatting. |
| .gitignore | Ignores local repro scripts/logs and dev log. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if err := tx.MetadataRepoMode(); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
This change is part of the shared commits from PR #492 (fix for #470). The switch to MetadataRepoMode() in these calls is intentional: the table is being registered in the metadata catalog during the streaming-insert resolution path, not written to the content store. PR #492 contains the full explanation and test coverage for this change.
| if err := tx.MetadataRepoMode(); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
Same as above — this is part of the shared commits from PR #492. Addressed there.
| func (t *Tx) RawExec(ctx context.Context, query string, args ...interface{}) error { | ||
| var execErr error | ||
| if err := t.conn.Conn.Raw(func(c interface{}) error { | ||
| gsqlConn, ok := c.(*googlesqlite.Conn) | ||
| if !ok { | ||
| return fmt.Errorf("unexpected driver connection type %T", c) | ||
| } | ||
| f := reflect.ValueOf(gsqlConn).Elem().FieldByName("conn") | ||
| if !f.IsValid() { | ||
| return fmt.Errorf("googlesqlite.Conn has no 'conn' field") | ||
| } | ||
| innerConn := *(**sql.Conn)(unsafe.Pointer(f.UnsafeAddr())) | ||
| _, execErr = innerConn.ExecContext(ctx, query, args...) | ||
| return nil | ||
| }); err != nil { | ||
| return err | ||
| } | ||
| return execErr | ||
| } |
There was a problem hiding this comment.
Fixed in commit 5b3b693. Extracted the reflect+unsafe access into a dedicated innerSQLConn helper that validates f.Kind() == reflect.Ptr and f.Type() == reflect.TypeOf((*sql.Conn)(nil)) before the pointer reinterpretation. A type mismatch now returns a clear error instead of causing a panic or memory corruption.
| for _, action := range actions { | ||
| if action.kind != "ADD_COLUMN" { | ||
| continue | ||
| } | ||
| colType, err := tf.MakeSimpleType(googlesql.TypeKind(bqTypeKind(action.colType))) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to make ZetaSQL type for column %q: %w", action.colName, err) | ||
| } | ||
| newCol, err := googlesql.NewSimpleColumn(sqliteTableName, action.colName, colType, false, true) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create SimpleColumn %q: %w", action.colName, err) | ||
| } | ||
| if err := target.AddColumn(newCol); err != nil { | ||
| return fmt.Errorf("failed to add column %q to ZetaSQL table: %w", action.colName, err) | ||
| } | ||
| } |
There was a problem hiding this comment.
Acknowledged. go-googlesql's SimpleTable does not expose a RemoveColumn or RenameColumn method, so rebuilding the in-memory table after DROP/RENAME would require re-registering the table in the SimpleCatalog — which also lacks a RemoveTable/ReplaceTable API. The googlesqlite_catalog UPSERT keeps the persisted schema correct for the next sync cycle. In-memory catalog divergence for DROP/RENAME within the same connection is a known limitation of this approach, documented in the function comment. Full support would require an upstream API addition to go-googlesql.
| func isAlterTableQuery(query string) bool { | ||
| q := strings.ToUpper(strings.TrimSpace(query)) | ||
| return strings.HasPrefix(q, "ALTER") && strings.Contains(q, "TABLE") | ||
| } |
There was a problem hiding this comment.
Fixed in commit 5b3b693. Changed to strings.HasPrefix(q, "ALTER TABLE ") (with trailing space) so only actual ALTER TABLE statements are routed through the DDL path.
| kind: "DROP_COLUMN", | ||
| colName: colName, | ||
| }) | ||
| case *googlesql.ASTRenameColumnAction: |
There was a problem hiding this comment.
Fixed in commit 5b3b693. Added a default branch in parseAlterTable that returns fmt.Errorf("alter table: unsupported action type %T", node). Unknown actions now surface as an explicit error so the job fails cleanly instead of returning a false success.
| } | ||
| } | ||
| return tablePath, actions, nil |
There was a problem hiding this comment.
Fixed in commit 5b3b693. Added a default branch in parseAlterTable that returns fmt.Errorf("alter table: unsupported action type %T", node). Unknown actions now surface as an explicit error so the job fails cleanly instead of returning a false success.
| func bqTypeKind(typeName string) int { | ||
| switch strings.ToUpper(typeName) { |
There was a problem hiding this comment.
Fixed in commit 5b3b693. bqTypeKind now returns (int, bool). All three call sites check the boolean and return a descriptive error on unrecognised type names, preventing silent schema corruption.
| default: | ||
| return int(googlesql.TypeKindTypeString) | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in commit 5b3b693. bqTypeKind now returns (int, bool). All three call sites check the boolean and return a descriptive error on unrecognised type names, preventing silent schema corruption.
| package server | ||
|
|
||
| import "testing" |
There was a problem hiding this comment.
This file is part of the shared commits from PR #492 (commit 0160fc5). The CRLF issue appears in the diff because the file was written on Windows but the repository uses core.eol=lf. The committed object in the repository already has LF-only line endings — git show HEAD:server/handler_tableid_test.go | file - confirms this. It is a working-tree display artefact on Windows, not a committed problem.
- isAlterTableQuery: use HasPrefix("ALTER TABLE ") instead of separate
HasPrefix("ALTER")+Contains("TABLE") to avoid mis-routing unrelated
statements.
- bqTypeKind: return (int, bool) so callers get an explicit error on
unrecognised type names instead of silently falling back to STRING.
- parseAlterTable: add default branch returning an error for unsupported
action types so unknown actions are rejected, not silently ignored.
- innerSQLConn: extract reflect+unsafe cast into a helper that verifies
field kind and exact type before the pointer reinterpretation.
Problem
googlesqlitetreatsALTER TABLEas a no-op (NoopStmtAction): the underlying SQLite schema and the internal ZetaSQL catalog are never updated, so columns added viaADD COLUMNare invisible to subsequentINSERT/SELECTanalysis — the query fails with "Column X is not present in table".Closes #481
Approach
The fix intercepts
ALTER TABLEin bothjobsInsertHandlerandjobsQueryHandlerand applies three coordinated updates within the active transaction:ADD COLUMN,DROP COLUMN,RENAME COLUMN) executed viareflect+unsafeon the private inner*sql.DBinsidegooglesqlite.Conn.googlesqlite_catalogwith the newTableSpecJSON so the persisted catalog stays consistent.AddColumnon the WASM-sideSimpleTableinside theSimpleCatalog, making the column visible to the ZetaSQL analyzer immediately without reloading the catalog.After commit,
updateTableMetadatapersists the updated BigQuery schema so the REST API returns the correct field list. All parsing is driven by the ZetaSQL AST — nothing is hardcoded.Trade-offs and risks
This fix accesses private fields of
googlesqlite.Connusingreflect+unsafe. This is an acknowledged workaround for a gap in the public API ofgooglesqlite. The approach is self-contained — the unsafe pointer arithmetic is isolated to the ALTER TABLE handler and will need to be revisited if the internal structure ofgooglesqlite.Connchanges in a future version.If the team prefers not to merge this until
googlesqliteexposes an official hook, this PR can be deferred independently without blocking the other fixes in PR #492.Test plan
TestDDLAlterTable(server/server_test.go) — coversCREATE TABLE,ALTER TABLE ADD COLUMN, metadata verification via the REST API,INSERTwith the new column, andSELECTto confirm round-trip correctness.