Skip to content

Commit e94d244

Browse files
authored
feat(iceberg): honor IF [NOT] EXISTS and support WRITE ORDERED BY sort order (#66)
Make the whole IF [NOT] EXISTS family idempotent and add table write sort order DDL to the Iceberg driver. IF [NOT] EXISTS (fix): - The parser captured but discarded the clause for CREATE/DROP TABLE, and did not understand it at all for DROP NAMESPACE — so re-running release/rollback failed with AlreadyExistsException / NoSuchTable. The parser now records both IfNotExists and IfExists, and ExecQuery probes existence before create/drop. - New catalog TableExists: HEAD-first CheckTableExists with a GET-based ListTables fallback (SetPageSize(0)) for older REST catalogs that reject HEAD, caching the capability so a failed HEAD happens at most once per run. WRITE ORDERED BY (feature): - ALTER TABLE <id> WRITE ORDERED BY <col> [ASC|DESC] [NULLS FIRST|LAST], ... and WRITE UNORDERED set/clear the table write sort order. - Applied via catalog CommitTable (AddSortOrder + SetDefaultSortOrder, guarded by AssertTableUUID); iceberg-go has no sort-order transaction builder. - Sort columns reuse the partition transform vocabulary (bucket/truncate/ days/...); direction defaults to ASC, null ordering to Iceberg defaults. Covered by parser + repository unit tests and integration tests against the iceberg-rest + MinIO stack; README DDL subset updated.
1 parent f0b3f18 commit e94d244

11 files changed

Lines changed: 879 additions & 14 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,9 +532,16 @@ The supported subset covers the most common schema-evolution DDL (v1):
532532
| `ALTER TABLE <id> ALTER COLUMN <name> TYPE <type>` | Change a column type (widening only) |
533533
| `ALTER TABLE <id> ADD PARTITION FIELD <transform>(<col>)` | Add a partition field |
534534
| `ALTER TABLE <id> DROP PARTITION FIELD <transform>(<col>)` | Drop a partition field |
535+
| `ALTER TABLE <id> WRITE ORDERED BY <col> [ASC\|DESC] [NULLS FIRST\|LAST], …` | Set the table write sort order |
536+
| `ALTER TABLE <id> WRITE UNORDERED` | Clear the table write sort order |
535537

536538
SQL comments (`--` and `/* */`) are supported inside migration files.
537539

540+
`WRITE ORDERED BY` accepts plain columns or partition-style transforms (e.g. `bucket(8, id)`,
541+
`days(ts)`). Direction defaults to `ASC`; null ordering defaults to `NULLS FIRST` for `ASC` and
542+
`NULLS LAST` for `DESC` (Iceberg convention). Setting a sort order is not automatically
543+
reversible — a `.down.sql` should restore the previous order explicitly or use `WRITE UNORDERED`.
544+
538545
**Supported column types:**
539546

540547
| Spark SQL type | Iceberg type | Notes |

internal/application/handler/integration_iceberg_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,146 @@ func TestIntegration_Iceberg_MultiLevelNamespace(t *testing.T) {
10401040
})
10411041
}
10421042

1043+
// TestIntegration_Iceberg_IfNotExistsIdempotent reproduces the reported bug where
1044+
// `release` with a duplicate `CREATE TABLE IF NOT EXISTS` failed with AlreadyExistsException,
1045+
// and verifies the whole IF [NOT] EXISTS family is now idempotent end-to-end against the catalog:
1046+
// - CREATE TABLE IF NOT EXISTS on an existing table is skipped (not an error);
1047+
// - DROP TABLE IF EXISTS on an already-dropped table is skipped;
1048+
// - DROP NAMESPACE IF EXISTS drops the namespace.
1049+
func TestIntegration_Iceberg_IfNotExistsIdempotent(t *testing.T) {
1050+
if testing.Short() {
1051+
t.Skip("skipping integration test")
1052+
}
1053+
loadIcebergEnv(t)
1054+
1055+
tmpDir := t.TempDir()
1056+
write := func(name, content string) {
1057+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o600))
1058+
}
1059+
1060+
// Namespace (bare name, like the reference fixtures).
1061+
write("260301_100000_ns.up.sql", "CREATE NAMESPACE IF NOT EXISTS idemp;\n")
1062+
write("260301_100000_ns.down.sql", "DROP NAMESPACE IF EXISTS idemp;\n")
1063+
// First table creation. DSN warehouse = iceberg, so the leading segment is stripped:
1064+
// iceberg.idemp.widgets => namespace=[idemp], table=widgets.
1065+
write("260301_100100_tbl.up.sql", "CREATE TABLE IF NOT EXISTS iceberg.idemp.widgets (id long);\n")
1066+
write("260301_100100_tbl.down.sql", "DROP TABLE IF EXISTS iceberg.idemp.widgets;\n")
1067+
// Duplicate creation of the SAME table: must be skipped idempotently, not fail.
1068+
write("260301_100200_tbl_dup.up.sql", "CREATE TABLE IF NOT EXISTS iceberg.idemp.widgets (id long);\n")
1069+
// Its down drops the table; the previous migration's down then hits DROP TABLE IF EXISTS
1070+
// on an already-dropped table, exercising the idempotent-drop path.
1071+
write("260301_100200_tbl_dup.down.sql", "DROP TABLE IF EXISTS iceberg.idemp.widgets;\n")
1072+
1073+
opts := &Options{
1074+
DSN: icebergDSN(),
1075+
Directory: tmpDir,
1076+
TableName: "mig_ifnotexists",
1077+
Compact: true,
1078+
Interactive: false,
1079+
}
1080+
handlers := NewHandlers(opts, &infralog.NopLogger{})
1081+
1082+
createCommand := func(arg string) *Command {
1083+
args := NewMockArgs(t)
1084+
args.EXPECT().First().Return(arg).Maybe()
1085+
args.EXPECT().Present().Return(true).Maybe()
1086+
return &Command{Args: args}
1087+
}
1088+
1089+
conn, err := connection.Try(opts.DSN, 1)
1090+
require.NoError(t, err)
1091+
defer conn.Close()
1092+
1093+
repo, err := repository.New(conn, &repository.Options{TableName: opts.TableName})
1094+
require.NoError(t, err)
1095+
1096+
ctx := context.Background()
1097+
1098+
t.Run("release_with_duplicate_create_table_is_idempotent", func(t *testing.T) {
1099+
cleanupIceberg(handlers, createCommand)
1100+
defer func() { cleanupIceberg(handlers, createCommand) }()
1101+
1102+
// release applies all three migrations in one batch. The duplicate
1103+
// CREATE TABLE IF NOT EXISTS must be skipped, not raise AlreadyExistsException.
1104+
err = handlers.Release.Handle(createCommand(""))
1105+
require.NoError(t, err, "duplicate CREATE TABLE IF NOT EXISTS must be skipped, not fail")
1106+
assertIcebergMigrationsCount(t, ctx, repo, 4) // base + 3
1107+
1108+
// down all reverts in reverse order; the second DROP TABLE IF EXISTS lands on an
1109+
// already-dropped table (idempotent skip), then DROP NAMESPACE IF EXISTS removes it.
1110+
err = handlers.Downgrade.Handle(createCommand("all"))
1111+
require.NoError(t, err)
1112+
assertIcebergMigrationsCount(t, ctx, repo, 1) // base only
1113+
})
1114+
}
1115+
1116+
// TestIntegration_Iceberg_WriteOrderedBy verifies the ALTER TABLE … WRITE ORDERED BY / WRITE
1117+
// UNORDERED sort-order operations end-to-end against the real catalog: a sort order with a plain
1118+
// column and a transform is committed via CommitTable, and the down migration resets it with
1119+
// WRITE UNORDERED.
1120+
func TestIntegration_Iceberg_WriteOrderedBy(t *testing.T) {
1121+
if testing.Short() {
1122+
t.Skip("skipping integration test")
1123+
}
1124+
loadIcebergEnv(t)
1125+
1126+
tmpDir := t.TempDir()
1127+
write := func(name, content string) {
1128+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o600))
1129+
}
1130+
1131+
write("260401_100000_ns.up.sql", "CREATE NAMESPACE IF NOT EXISTS sortns;\n")
1132+
write("260401_100000_ns.down.sql", "DROP NAMESPACE IF EXISTS sortns;\n")
1133+
write("260401_100100_tbl.up.sql",
1134+
"CREATE TABLE iceberg.sortns.orders (id long, amount long, created_at timestamp);\n")
1135+
write("260401_100100_tbl.down.sql", "DROP TABLE IF EXISTS iceberg.sortns.orders;\n")
1136+
// Sort order over a plain column (with explicit null ordering) and a bucket transform.
1137+
write("260401_100200_sort.up.sql",
1138+
"ALTER TABLE iceberg.sortns.orders WRITE ORDERED BY created_at DESC NULLS LAST, bucket(8, id);\n")
1139+
// Reverting a sort order is not automatic — reset to unsorted.
1140+
write("260401_100200_sort.down.sql", "ALTER TABLE iceberg.sortns.orders WRITE UNORDERED;\n")
1141+
1142+
opts := &Options{
1143+
DSN: icebergDSN(),
1144+
Directory: tmpDir,
1145+
TableName: "mig_sortorder",
1146+
Compact: true,
1147+
Interactive: false,
1148+
}
1149+
handlers := NewHandlers(opts, &infralog.NopLogger{})
1150+
1151+
createCommand := func(arg string) *Command {
1152+
args := NewMockArgs(t)
1153+
args.EXPECT().First().Return(arg).Maybe()
1154+
args.EXPECT().Present().Return(true).Maybe()
1155+
return &Command{Args: args}
1156+
}
1157+
1158+
conn, err := connection.Try(opts.DSN, 1)
1159+
require.NoError(t, err)
1160+
defer conn.Close()
1161+
1162+
repo, err := repository.New(conn, &repository.Options{TableName: opts.TableName})
1163+
require.NoError(t, err)
1164+
1165+
ctx := context.Background()
1166+
1167+
t.Run("apply_sort_order_and_reset", func(t *testing.T) {
1168+
cleanupIceberg(handlers, createCommand)
1169+
defer func() { cleanupIceberg(handlers, createCommand) }()
1170+
1171+
// up applies namespace + table + WRITE ORDERED BY (commits sort order via CommitTable).
1172+
err = handlers.Upgrade.Handle(createCommand(""))
1173+
require.NoError(t, err, "WRITE ORDERED BY must commit the sort order successfully")
1174+
assertIcebergMigrationsCount(t, ctx, repo, 4) // base + 3
1175+
1176+
// down all reverts: WRITE UNORDERED (reset), DROP TABLE, DROP NAMESPACE.
1177+
err = handlers.Downgrade.Handle(createCommand("all"))
1178+
require.NoError(t, err)
1179+
assertIcebergMigrationsCount(t, ctx, repo, 1) // base only
1180+
})
1181+
}
1182+
10431183
// Compile-time verification that *testCapturingLogger satisfies Logger.
10441184
// Uses the Logger type alias defined in dependency.go (= log.Logger interface).
10451185
var _ Logger = (*testCapturingLogger)(nil)

internal/infrastructure/dal/repository/dependency.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type IcebergCatalog interface {
3333

3434
// CreateTable creates an Iceberg table from the given IR specification.
3535
CreateTable(ctx context.Context, ident ddl.Ident, spec ddl.CreateTableSpec) error
36+
// TableExists checks whether the given table exists in the catalog.
37+
TableExists(ctx context.Context, ident ddl.Ident) (bool, error)
3638
// DropTable drops an Iceberg table identified by ident.
3739
DropTable(ctx context.Context, ident ddl.Ident) error
3840
// RenameTable renames an Iceberg table from from to to.
@@ -43,6 +45,9 @@ type IcebergCatalog interface {
4345
// ApplySpecChange applies a partition-spec DDL operation (AddPartitionField,
4446
// DropPartitionField) via an Iceberg spec update transaction.
4547
ApplySpecChange(ctx context.Context, op ddl.Operation) error
48+
// ApplySortOrderChange sets or clears the table write sort order (WRITE ORDERED BY /
49+
// WRITE UNORDERED) via a catalog CommitTable call.
50+
ApplySortOrderChange(ctx context.Context, op ddl.Operation) error
4651
}
4752

4853
//go:generate mockery

internal/infrastructure/dal/repository/iceberg.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,40 @@ func (i *Iceberg) ExecQuery(ctx context.Context, query string, _ ...any) error {
210210
}
211211
return i.cat.CreateNamespace(ctx, op.Table.Namespace, op.Props)
212212
case ddl.DropNamespace:
213+
if op.IfExists {
214+
exists, err := i.cat.NamespaceExists(ctx, op.Table.Namespace)
215+
if err != nil {
216+
return err
217+
}
218+
if !exists {
219+
return nil
220+
}
221+
}
213222
return i.cat.DropNamespace(ctx, op.Table.Namespace)
214223
case ddl.CreateTable:
215224
if op.Create == nil {
216225
return errors.New("iceberg: CreateTable IR has nil Create spec")
217226
}
227+
if op.IfNotExists {
228+
exists, err := i.cat.TableExists(ctx, op.Table)
229+
if err != nil {
230+
return err
231+
}
232+
if exists {
233+
return nil
234+
}
235+
}
218236
return i.cat.CreateTable(ctx, op.Table, *op.Create)
219237
case ddl.DropTable:
238+
if op.IfExists {
239+
exists, err := i.cat.TableExists(ctx, op.Table)
240+
if err != nil {
241+
return err
242+
}
243+
if !exists {
244+
return nil
245+
}
246+
}
220247
return i.cat.DropTable(ctx, op.Table)
221248
case ddl.RenameTable:
222249
if op.RenameTo == nil {
@@ -227,6 +254,8 @@ func (i *Iceberg) ExecQuery(ctx context.Context, query string, _ ...any) error {
227254
return i.cat.ApplySchemaChange(ctx, op)
228255
case ddl.AddPartitionField, ddl.DropPartitionField:
229256
return i.cat.ApplySpecChange(ctx, op)
257+
case ddl.SetSortOrder:
258+
return i.cat.ApplySortOrderChange(ctx, op)
230259
default:
231260
return errors.WithStack(ddl.ErrUnsupportedDDL)
232261
}

internal/infrastructure/dal/repository/iceberg_catalog_mock_test.go

Lines changed: 104 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)