Skip to content

Commit f7b7013

Browse files
author
wei
committed
feat: persist table storage selection
1 parent 5a3a8b5 commit f7b7013

9 files changed

Lines changed: 1124 additions & 11 deletions

File tree

backend/engine.go

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
sqle "github.com/dolthub/go-mysql-server"
2323
"github.com/dolthub/go-mysql-server/sql"
2424
"github.com/dolthub/go-mysql-server/sql/analyzer"
25+
"github.com/dolthub/go-mysql-server/sql/plan"
2526
"github.com/dolthub/go-mysql-server/sql/types"
2627
"github.com/dolthub/vitess/go/vt/sqlparser"
2728
)
@@ -33,13 +34,37 @@ func NewEngine(provider *catalog.DatabaseProvider) (*sqle.Engine, *DuckBuilder)
3334
parser := &mysqlParser{Parser: sql.NewMysqlParser()}
3435
overrides := sql.EngineOverrides{
3536
Builder: sql.BuilderOverrides{Parser: parser},
37+
Hooks: sql.ExecutionHooks{
38+
CreateTable: sql.CreateTable{
39+
PreSQLExecution: prepareMySQLCreateTableStorage,
40+
},
41+
},
3642
}
3743
engine := sqle.New(analyzer.NewBuilder(provider).AddOverrides(overrides).Build(), nil)
3844
builder := NewDuckBuilder(engine.Analyzer.ExecBuilder, provider)
3945
engine.Analyzer.ExecBuilder.PriorityBuilder = builder
4046
return engine, builder
4147
}
4248

49+
// prepareMySQLCreateTableStorage bridges the planner's table-option map to
50+
// the catalog's request-scoped storage selector. The catalog consumes the
51+
// selector while creating the table and persists it in the managed comment;
52+
// no credentials, endpoints, or object paths are accepted from SQL.
53+
func prepareMySQLCreateTableStorage(ctx *sql.Context, _ sql.StatementRunner, node sql.Node) (sql.Node, error) {
54+
create, ok := node.(*plan.CreateTable)
55+
if !ok {
56+
return node, nil
57+
}
58+
selection, err := catalog.ResolveMySQLTableStorage(create.TableOpts)
59+
if err != nil {
60+
return nil, err
61+
}
62+
if err := catalog.SetTableStorageSelection(ctx, selection); err != nil {
63+
return nil, err
64+
}
65+
return create, nil
66+
}
67+
4368
// registerMySQLCompatibilitySystemVariables keeps MyDuck's advertised SQL
4469
// compatibility level stable across GMS upgrades. Clients such as MySQL Shell
4570
// branch on @@version and otherwise probe newer variables MyDuck does not
@@ -67,13 +92,21 @@ type mysqlParser struct {
6792
func (p *mysqlParser) ParseSimple(query string) (sqlparser.Statement, error) {
6893
compat := rewriteMySQLCompatibility(query)
6994
stmt, err := p.Parser.ParseSimple(compat.query)
70-
return normalizeMySQLStatement(stmt, compat.replacements), err
95+
stmt = normalizeMySQLStatement(stmt, compat.replacements)
96+
if err == nil {
97+
err = validateMySQLTableStorageStatement(stmt)
98+
}
99+
return stmt, err
71100
}
72101

73102
func (p *mysqlParser) Parse(ctx *sql.Context, query string, multi bool) (sqlparser.Statement, string, string, error) {
74103
compat := rewriteMySQLCompatibility(query)
75104
stmt, parsed, remainder, err := p.Parser.Parse(ctx, compat.query, multi)
76-
return normalizeMySQLStatement(stmt, compat.replacements), compat.restoreParsedQuery(parsed), remainder, err
105+
stmt = normalizeMySQLStatement(stmt, compat.replacements)
106+
if err == nil {
107+
err = validateMySQLTableStorageStatement(stmt)
108+
}
109+
return stmt, compat.restoreParsedQuery(parsed), remainder, err
77110
}
78111

79112
func (p *mysqlParser) ParseWithOptions(
@@ -85,7 +118,11 @@ func (p *mysqlParser) ParseWithOptions(
85118
) (sqlparser.Statement, string, string, error) {
86119
compat := rewriteMySQLCompatibility(query)
87120
stmt, parsed, remainder, err := p.Parser.ParseWithOptions(ctx, compat.query, delimiter, multi, options)
88-
return normalizeMySQLStatement(stmt, compat.replacements), compat.restoreParsedQuery(parsed), remainder, err
121+
stmt = normalizeMySQLStatement(stmt, compat.replacements)
122+
if err == nil {
123+
err = validateMySQLTableStorageStatement(stmt)
124+
}
125+
return stmt, compat.restoreParsedQuery(parsed), remainder, err
89126
}
90127

91128
func (p *mysqlParser) ParseOneWithOptions(
@@ -95,7 +132,33 @@ func (p *mysqlParser) ParseOneWithOptions(
95132
) (sqlparser.Statement, int, error) {
96133
compat := rewriteMySQLCompatibility(query)
97134
stmt, index, err := p.Parser.ParseOneWithOptions(ctx, compat.query, options)
98-
return normalizeMySQLStatement(stmt, compat.replacements), compat.originalOffset(index), err
135+
stmt = normalizeMySQLStatement(stmt, compat.replacements)
136+
if err == nil {
137+
err = validateMySQLTableStorageStatement(stmt)
138+
}
139+
return stmt, compat.originalOffset(index), err
140+
}
141+
142+
// validateMySQLTableStorageStatement runs before the planner turns table
143+
// options into a map. That preserves duplicate ENGINE/myduck_storage
144+
// declarations, which would otherwise be silently overwritten by the map.
145+
func validateMySQLTableStorageStatement(stmt sqlparser.Statement) error {
146+
ddl, ok := stmt.(*sqlparser.DDL)
147+
if !ok || ddl.TableSpec == nil || len(ddl.TableSpec.TableOpts) == 0 {
148+
return nil
149+
}
150+
options := make([]catalog.TableStorageOption, 0, len(ddl.TableSpec.TableOpts))
151+
for _, option := range ddl.TableSpec.TableOpts {
152+
if option == nil {
153+
continue
154+
}
155+
options = append(options, catalog.TableStorageOption{
156+
Name: option.Name,
157+
Value: option.Value,
158+
})
159+
}
160+
_, err := catalog.NormalizeTableStorageOptions(options)
161+
return err
99162
}
100163

101164
type mysqlOptionReplacement struct {

backend/engine_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"strings"
1919
"testing"
2020

21+
"github.com/apecloud/myduckserver/catalog"
2122
"github.com/dolthub/go-mysql-server/sql"
2223
"github.com/dolthub/vitess/go/vt/sqlparser"
2324
"github.com/stretchr/testify/require"
@@ -154,3 +155,36 @@ func TestMySQLParserRestoresDatabaseFiltersInMultiQuery(t *testing.T) {
154155
require.NoError(t, err)
155156
require.Equal(t, strings.Index(query, " SELECT 1"), index)
156157
}
158+
159+
func TestMySQLParserTableStorageOptions(t *testing.T) {
160+
parser := &mysqlParser{Parser: sql.NewMysqlParser()}
161+
for _, query := range []string{
162+
"CREATE TABLE object_table (id INT) ENGINE=DUCKLAKE",
163+
"CREATE TABLE local_table (id INT) ENGINE=InnoDB",
164+
} {
165+
stmt, _, _, err := parser.ParseWithOptions(context.Background(), query, ';', false, sqlparser.ParserOptions{})
166+
require.NoError(t, err, query)
167+
ddl, ok := stmt.(*sqlparser.DDL)
168+
require.True(t, ok, query)
169+
require.NotNil(t, ddl.TableSpec, query)
170+
require.NotEmpty(t, ddl.TableSpec.TableOpts, query)
171+
for _, option := range ddl.TableSpec.TableOpts {
172+
t.Logf("%s => name=%q value=%q", query, option.Name, option.Value)
173+
}
174+
}
175+
}
176+
177+
func TestMySQLParserRejectsConflictingTableStorageOptions(t *testing.T) {
178+
parser := &mysqlParser{Parser: sql.NewMysqlParser()}
179+
for _, test := range []struct {
180+
query string
181+
want error
182+
}{
183+
{query: "CREATE TABLE duplicate_engine (id INT) ENGINE=DUCKLAKE ENGINE=DUCKLAKE", want: catalog.ErrTableStorageDuplicate},
184+
{query: "CREATE TABLE conflicting_engine (id INT) ENGINE=DUCKLAKE ENGINE=LOCAL", want: catalog.ErrTableStorageConflict},
185+
} {
186+
_, _, _, err := parser.ParseWithOptions(context.Background(), test.query, ';', false, sqlparser.ParserOptions{})
187+
require.Error(t, err, test.query)
188+
require.ErrorIs(t, err, test.want, test.query)
189+
}
190+
}

catalog/database.go

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,16 @@ func (d *Database) Name() string {
146146
return d.name
147147
}
148148

149-
func (d *Database) createAllTable(ctx *sql.Context, name string, schema sql.PrimaryKeySchema, collation sql.CollationID, comment string, temporary bool) error {
149+
func (d *Database) createAllTable(ctx *sql.Context, name string, schema sql.PrimaryKeySchema, collation sql.CollationID, comment string, storage TableStorageSelection, temporary bool) error {
150+
if err := storage.Validate(); err != nil {
151+
return err
152+
}
153+
if temporary && storage.IsObjectStorage() {
154+
return fmt.Errorf("%w: temporary tables cannot use object storage", ErrInvalidTableStorage)
155+
}
156+
if storage.Kind == "" {
157+
storage = DefaultTableStorageSelection()
158+
}
150159
var columns []string
151160
var columnCommentSQLs []string
152161
var fullTableName string
@@ -269,7 +278,13 @@ func (d *Database) createAllTable(ctx *sql.Context, name string, schema sql.Prim
269278
b.WriteString(")")
270279

271280
// Add comment to the table
272-
info := ExtraTableInfo{schema.PkOrdinals, withoutIndex, fullSequenceName, nil}
281+
info := ExtraTableInfo{
282+
PkOrdinals: schema.PkOrdinals,
283+
Replicated: withoutIndex,
284+
Sequence: fullSequenceName,
285+
Checks: nil,
286+
Storage: storage.Kind,
287+
}
273288
b.WriteString(fmt.Sprintf(
274289
"; COMMENT ON TABLE %s IS '%s'",
275290
fullTableName,
@@ -322,14 +337,88 @@ func isIndexCreationDisabled(ctx *sql.Context) bool {
322337
func (d *Database) CreateTable(ctx *sql.Context, name string, schema sql.PrimaryKeySchema, collation sql.CollationID, comment string) error {
323338
d.mu.Lock()
324339
defer d.mu.Unlock()
325-
return d.createAllTable(ctx, name, schema, collation, comment, false)
340+
storage := DefaultTableStorageSelection()
341+
if selected, ok := TableStorageSelectionFromContext(ctx); ok {
342+
storage = selected
343+
}
344+
return d.createAllTable(ctx, name, schema, collation, comment, storage, false)
345+
}
346+
347+
// CreateTableWithStorage is the explicit catalog boundary for protocol
348+
// adapters that already normalized a table selector. It is intentionally
349+
// limited to selection propagation and metadata; object-table physical
350+
// routing is owned by the follow-up storage implementation.
351+
func (d *Database) CreateTableWithStorage(ctx *sql.Context, name string, schema sql.PrimaryKeySchema, collation sql.CollationID, comment string, storage TableStorageSelection) error {
352+
if err := storage.Validate(); err != nil {
353+
return err
354+
}
355+
if err := SetTableStorageSelection(ctx, storage); err != nil {
356+
return err
357+
}
358+
d.mu.Lock()
359+
defer d.mu.Unlock()
360+
return d.createAllTable(ctx, name, schema, collation, comment, storage, false)
361+
}
362+
363+
// RecordTableStorageSelection updates the managed table metadata for a table
364+
// created by a protocol path that bypasses sql.TableCreator (currently the
365+
// PostgreSQL handler). It preserves the user-visible table comment and makes
366+
// the selection available after a fresh catalog reload.
367+
func (d *Database) RecordTableStorageSelection(ctx *sql.Context, name string, storage TableStorageSelection) error {
368+
if err := storage.Validate(); err != nil {
369+
return err
370+
}
371+
if d.catalog == "temp" && storage.IsObjectStorage() {
372+
return fmt.Errorf("%w: temporary tables cannot use object storage", ErrInvalidTableStorage)
373+
}
374+
375+
d.mu.Lock()
376+
defer d.mu.Unlock()
377+
378+
rows, err := adapter.QueryCatalog(ctx, `
379+
SELECT comment
380+
FROM duckdb_tables()
381+
WHERE database_name = ? AND schema_name = ? AND table_name = ?
382+
`, d.catalog, d.name, name)
383+
if err != nil {
384+
return ErrDuckDB.New(err)
385+
}
386+
defer rows.Close()
387+
if !rows.Next() {
388+
if err := rows.Err(); err != nil {
389+
return ErrDuckDB.New(err)
390+
}
391+
return sql.ErrTableNotFound.New(name)
392+
}
393+
394+
var rawComment stdsql.NullString
395+
if err := rows.Scan(&rawComment); err != nil {
396+
_ = rows.Close()
397+
return ErrDuckDB.New(err)
398+
}
399+
if err := rows.Close(); err != nil {
400+
return ErrDuckDB.New(err)
401+
}
402+
comment := DecodeComment[ExtraTableInfo](rawComment.String)
403+
info := comment.Meta
404+
info.Storage = storage.Kind
405+
encoded := NewCommentWithMeta(comment.Text, info).Encode()
406+
_, err = adapter.Exec(ctx, fmt.Sprintf(`COMMENT ON TABLE %s IS '%s'`, FullTableName(d.catalog, d.name, name), encoded))
407+
if err != nil {
408+
return ErrDuckDB.New(err)
409+
}
410+
return nil
326411
}
327412

328413
// CreateTemporaryTable implements sql.CreateTemporaryTable.
329414
func (d *Database) CreateTemporaryTable(ctx *sql.Context, name string, schema sql.PrimaryKeySchema, collation sql.CollationID) error {
330415
d.mu.Lock()
331416
defer d.mu.Unlock()
332-
return d.createAllTable(ctx, name, schema, collation, "", true)
417+
storage := DefaultTableStorageSelection()
418+
if selected, ok := TableStorageSelectionFromContext(ctx); ok {
419+
storage = selected
420+
}
421+
return d.createAllTable(ctx, name, schema, collation, "", storage, true)
333422
}
334423

335424
// DropTable implements sql.TableDropper.

catalog/table.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ type ExtraTableInfo struct {
3232
Replicated bool
3333
Sequence string
3434
Checks []sql.CheckDefinition
35+
// Storage records the table's durable storage class. An empty value is
36+
// treated as local for metadata written before table-level storage
37+
// selection existed; newly-created tables always write the explicit value.
38+
Storage TableStorageKind `json:"storage,omitempty"`
39+
}
40+
41+
// StorageKind returns the effective storage class for this metadata. Missing
42+
// storage metadata is the backwards-compatible local-table behavior.
43+
func (info ExtraTableInfo) StorageKind() TableStorageKind {
44+
if info.Storage == TableStorageObject {
45+
return TableStorageObject
46+
}
47+
return TableStorageLocal
48+
}
49+
50+
func (info *ExtraTableInfo) normalizeStorage() {
51+
if info == nil {
52+
return
53+
}
54+
info.Storage = info.StorageKind()
3555
}
3656

3757
type ColumnInfo struct {
@@ -75,6 +95,10 @@ func NewTable(db *Database, name string, hasPrimaryKey bool) *Table {
7595
}
7696

7797
func (t *Table) withComment(comment *Comment[ExtraTableInfo]) *Table {
98+
if comment == nil {
99+
comment = NewComment[ExtraTableInfo]("")
100+
}
101+
comment.Meta.normalizeStorage()
78102
t.comment = comment
79103
return t
80104
}
@@ -100,7 +124,12 @@ func (t *Table) withSchema(ctx *sql.Context) error {
100124
}
101125

102126
func (t *Table) ExtraTableInfo() ExtraTableInfo {
103-
return t.comment.Meta
127+
if t.comment == nil {
128+
return ExtraTableInfo{Storage: TableStorageLocal}
129+
}
130+
info := t.comment.Meta
131+
info.normalizeStorage()
132+
return info
104133
}
105134

106135
func (t *Table) HasPrimaryKey() bool {

0 commit comments

Comments
 (0)