This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/terraform-provider-iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new bcde0d5 Add `iceberg_tables` data source (#71)
bcde0d5 is described below
commit bcde0d59587de510e3293af0629b6e459b458c2f
Author: Dao Thanh Tung <[email protected]>
AuthorDate: Thu Oct 15 12:22:31 2026 +0100
Add `iceberg_tables` data source (#71)
Add iceberg_tables data source
Signed-off-by: dttung2905 <[email protected]>
---
README.md | 1 +
docs/data-sources/tables.md | 141 ++++++
docs/index.md | 1 +
.../data-sources/iceberg_tables/data-source.tf | 69 +++
internal/provider/data_source_tables.go | 303 ++++++++++++
internal/provider/data_source_tables_test.go | 520 +++++++++++++++++++++
internal/provider/provider.go | 1 +
7 files changed, 1036 insertions(+)
diff --git a/README.md b/README.md
index bdd5509..aeef6a1 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ This [Terraform](https://terraform.io) and
[OpenTofu](https://www.opentofu.org/)
- `iceberg_namespace`: Read metadata for an existing namespace (catalog
properties).
- `iceberg_table`: Read metadata for an existing table (schema, partition
spec, sort order, and catalog properties).
+- `iceberg_tables`: List table names in a namespace (catalog discovery).
## Supported Resources
diff --git a/docs/data-sources/tables.md b/docs/data-sources/tables.md
new file mode 100644
index 0000000..9637108
--- /dev/null
+++ b/docs/data-sources/tables.md
@@ -0,0 +1,141 @@
+---
+page_title: "iceberg_tables Data Source - Iceberg"
+subcategory: ""
+description: |-
+ Lists table names in an Iceberg namespace from the catalog.
+---
+
+<!--
+ - Licensed to the Apache Software Foundation (ASF) under one
+ - or more contributor license agreements. See the NOTICE file
+ - distributed with this work for additional information
+ - regarding copyright ownership. The ASF licenses this file
+ - to you under the Apache License, Version 2.0 (the
+ - "License"); you may not use this file except in compliance
+ - with the License. You may obtain a copy of the License at
+ -
+ - http://www.apache.org/licenses/LICENSE-2.0
+ -
+ - Unless required by applicable law or agreed to in writing,
+ - software distributed under the License is distributed on an
+ - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ - KIND, either express or implied. See the License for the
+ - specific language governing permissions and limitations
+ - under the License.
+ -->
+
+# iceberg_tables (Data Source)
+
+Lists table names in an Iceberg namespace from the catalog.
+
+Use this data source to discover existing tables in a namespace—for example,
to validate namespace contents, wire downstream jobs, or drive `for_each` over
tables created outside Terraform.
+
+- `tables` returns table names only (e.g. `events`, `orders`).
+- `identifiers` returns dot-separated full table identifiers (e.g.
`analytics.raw.events`), matching the `id` format used by
[`iceberg_table`](table.md).
+
+Listing is non-recursive: only tables directly in the given namespace are
returned, not tables in child namespaces.
+
+## Example Usage
+
+### List tables in a namespace
+
+```terraform
+data "iceberg_tables" "analytics" {
+ namespace = ["analytics", "raw"]
+}
+
+output "table_names" {
+ value = data.iceberg_tables.analytics.tables
+}
+
+output "table_identifiers" {
+ value = data.iceberg_tables.analytics.identifiers
+}
+```
+
+### Drive resources with `for_each`
+
+Use `tables` when the namespace is already known (short names). Use
`identifiers` when downstream systems need the full catalog path.
+
+```terraform
+data "iceberg_tables" "in_ns" {
+ namespace = ["analytics", "raw"]
+}
+
+resource "example_downstream" "per_table" {
+ for_each = toset(data.iceberg_tables.in_ns.tables)
+
+ table_name = each.value
+}
+
+output "full_table_paths" {
+ value = data.iceberg_tables.in_ns.identifiers
+}
+```
+
+### List tables after creating them in the same configuration
+
+When tables are managed in the same Terraform configuration, add `depends_on`
so the data source is read after those tables exist. Referencing only
`namespace` creates an implicit dependency on the namespace resource, not on
individual tables.
+
+```terraform
+resource "iceberg_namespace" "example" {
+ name = ["example_namespace"]
+}
+
+resource "iceberg_table" "events" {
+ namespace = iceberg_namespace.example.name
+ name = "events"
+
+ schema = {
+ fields = [
+ {
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+resource "iceberg_table" "orders" {
+ namespace = iceberg_namespace.example.name
+ name = "orders"
+
+ schema = {
+ fields = [
+ {
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+data "iceberg_tables" "example" {
+ namespace = iceberg_namespace.example.name
+
+ depends_on = [
+ iceberg_table.events,
+ iceberg_table.orders,
+ ]
+}
+```
+
+## Schema
+
+### Required
+
+- `namespace` (List of String) The namespace to list tables in.
+
+### Read-Only
+
+- `id` (String) Dot-separated full namespace identifier.
+- `tables` (List of String) Table names in the namespace, without namespace
segments. Sorted alphabetically.
+- `identifiers` (List of String) Dot-separated full table identifiers
(namespace + table name), e.g. `analytics.raw.events`. Sorted alphabetically.
Matches the `id` format of [`iceberg_table`](table.md).
+
+## Error handling
+
+- If the namespace does not exist, apply fails with a **Namespace not found**
error.
+- If listing fails for another reason (including a mid-pagination catalog
error), apply fails with **failed to list tables** and the underlying catalog
message.
+- If `catalog_uri` is not configured on the provider, apply fails with
**Catalog not available**.
diff --git a/docs/index.md b/docs/index.md
index 65120e2..c7239eb 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -197,6 +197,7 @@ carry duplicate credentials.
- [iceberg_namespace](data-sources/namespace.md) — Read metadata for an
existing namespace from the catalog.
- [iceberg_table](data-sources/table.md) — Read metadata for an existing table
from the catalog.
+- [iceberg_tables](data-sources/tables.md) — List table names in a namespace
from the catalog.
## Resources
diff --git a/examples/data-sources/iceberg_tables/data-source.tf
b/examples/data-sources/iceberg_tables/data-source.tf
new file mode 100644
index 0000000..145a719
--- /dev/null
+++ b/examples/data-sources/iceberg_tables/data-source.tf
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+resource "iceberg_namespace" "example" {
+ name = ["example_namespace"]
+}
+
+resource "iceberg_table" "events" {
+ namespace = iceberg_namespace.example.name
+ name = "events"
+
+ schema = {
+ fields = [
+ {
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+resource "iceberg_table" "orders" {
+ namespace = iceberg_namespace.example.name
+ name = "orders"
+
+ schema = {
+ fields = [
+ {
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+data "iceberg_tables" "example" {
+ namespace = iceberg_namespace.example.name
+
+ depends_on = [
+ iceberg_table.events,
+ iceberg_table.orders,
+ ]
+}
+
+output "table_names" {
+ value = data.iceberg_tables.example.tables
+}
+
+output "table_identifiers" {
+ value = data.iceberg_tables.example.identifiers
+}
+
+output "namespace_id" {
+ value = data.iceberg_tables.example.id
+}
diff --git a/internal/provider/data_source_tables.go
b/internal/provider/data_source_tables.go
new file mode 100644
index 0000000..1c24ca3
--- /dev/null
+++ b/internal/provider/data_source_tables.go
@@ -0,0 +1,303 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package provider
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "iter"
+ "slices"
+ "strings"
+
+ "github.com/apache/iceberg-go/catalog"
+ "github.com/apache/iceberg-go/table"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ dschema
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+)
+
+var _ datasource.DataSource = &icebergTablesDataSource{}
+
+func NewTablesDataSource() datasource.DataSource {
+ return &icebergTablesDataSource{}
+}
+
+type icebergTablesDataSourceModel struct {
+ ID types.String `tfsdk:"id"`
+ Namespace types.List `tfsdk:"namespace"`
+ Tables types.List `tfsdk:"tables"`
+ Identifiers types.List `tfsdk:"identifiers"`
+}
+
+type icebergTablesDataSource struct {
+ catalog catalog.Catalog
+ provider *icebergProvider
+}
+
+// errNamespaceNotFound is returned by collectListedTables only when ListTables
+// fails with catalog.ErrNoSuchNamespace and CheckNamespaceExists confirms the
+// namespace is absent. REST page-level HTTP 404s wrap the same catalog
sentinel,
+// so a listing failure against an existing namespace must not use this error.
+var errNamespaceNotFound = errors.New("namespace not found")
+
+// namespaceExister is the CheckNamespaceExists subset of catalog.Catalog used
+// to disambiguate ErrNoSuchNamespace on the ListTables error path.
+type namespaceExister interface {
+ CheckNamespaceExists(ctx context.Context, namespace table.Identifier)
(bool, error)
+}
+
+func (d *icebergTablesDataSource) Metadata(_ context.Context, req
datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_tables"
+}
+
+func (d *icebergTablesDataSource) Schema(_ context.Context, _
datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = dschema.Schema{
+ Description: "Lists table names in an Iceberg namespace from
the catalog.",
+ Attributes: map[string]dschema.Attribute{
+ "id": dschema.StringAttribute{
+ Description: "Dot-separated full namespace
identifier.",
+ Computed: true,
+ },
+ "namespace": dschema.ListAttribute{
+ Description: "The namespace to list tables in.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "tables": dschema.ListAttribute{
+ Description: "Table names in the namespace,
without namespace segments. Sorted alphabetically.",
+ Computed: true,
+ ElementType: types.StringType,
+ },
+ "identifiers": dschema.ListAttribute{
+ Description: "Dot-separated full table
identifiers (namespace segments + table name), matching iceberg_table id
format. Sorted alphabetically.",
+ Computed: true,
+ ElementType: types.StringType,
+ },
+ },
+ }
+}
+
+func (d *icebergTablesDataSource) Configure(ctx context.Context, req
datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ provider, ok := req.ProviderData.(*icebergProvider)
+ if !ok {
+ resp.Diagnostics.AddError(
+ "Unexpected Data Source Configure Type",
+ fmt.Sprintf("Expected *icebergProvider, got: %T. Please
report this issue to the provider developers.", req.ProviderData),
+ )
+
+ return
+ }
+
+ d.provider = provider
+}
+
+func (d *icebergTablesDataSource) configureCatalog(ctx context.Context, diags
*diag.Diagnostics) {
+ if d.catalog != nil {
+ return
+ }
+
+ if d.provider == nil {
+ diags.AddError(
+ "Provider not configured",
+ "The provider hasn't been configured before this
operation",
+ )
+
+ return
+ }
+
+ if d.provider.catalogURI == "" {
+ return
+ }
+
+ cat, err := d.provider.NewCatalog(ctx)
+ if err != nil {
+ diags.AddError(
+ "Failed to access catalog",
+ "Failed to access catalog: "+err.Error(),
+ )
+
+ return
+ }
+ d.catalog = cat
+}
+
+// identifierString formats a table identifier as a dot-separated string.
+// table.Identifier is currently a []string alias with no String() method;
+// keep this helper so formatting stays in one place if that changes before
v1.0.
+func identifierString(ident table.Identifier) string {
+ return strings.Join(ident, ".")
+}
+
+func sortTableIdentifiers(identifiers []table.Identifier) {
+ // Stable sort keeps list order deterministic across refreshes so
Terraform
+ // does not report spurious diffs when the catalog returns tables
unordered.
+ slices.SortStableFunc(identifiers, func(a, b table.Identifier) int {
+ return strings.Compare(identifierString(a), identifierString(b))
+ })
+}
+
+func tableNamesFromIdentifiers(identifiers []table.Identifier) []string {
+ names := make([]string, 0, len(identifiers))
+ for _, ident := range identifiers {
+ names = append(names, catalog.TableNameFromIdent(ident))
+ }
+
+ return names
+}
+
+func tableIdentifierStrings(identifiers []table.Identifier) []string {
+ out := make([]string, 0, len(identifiers))
+ for _, ident := range identifiers {
+ out = append(out, identifierString(ident))
+ }
+
+ return out
+}
+
+// collectListedTables consumes a ListTables iterator, enforcing that every
+// yielded identifier belongs to namespaceIdent (non-recursive listing).
+//
+// Yield count cannot distinguish a missing namespace from a page-level HTTP
404:
+// iceberg-go's REST catalog wraps every 404 with ErrNoSuchNamespace, and an
+// empty non-final page yields nothing before the error. On that sentinel,
probe
+// CheckNamespaceExists and only then report errNamespaceNotFound.
+func collectListedTables(
+ ctx context.Context,
+ seq iter.Seq2[table.Identifier, error],
+ namespaceIdent table.Identifier,
+ checkExists namespaceExister,
+) ([]table.Identifier, error) {
+ var tableIdents []table.Identifier
+ for ident, err := range seq {
+ if err != nil {
+ if errors.Is(err, catalog.ErrNoSuchNamespace) {
+ return nil, classifyNoSuchNamespace(ctx, err,
namespaceIdent, checkExists)
+ }
+
+ return nil, err
+ }
+
+ // NamespaceFromIdent is ident[:len(ident)-1] with no bounds
check and
+ // panics on an empty identifier; keep this guard first.
+ if len(ident) == 0 {
+ return nil, errors.New("catalog returned empty table
identifier")
+ }
+ if !slices.Equal(catalog.NamespaceFromIdent(ident),
namespaceIdent) {
+ return nil, fmt.Errorf(
+ "catalog returned table %q outside requested
namespace %q",
+ identifierString(ident),
+ identifierString(namespaceIdent),
+ )
+ }
+
+ tableIdents = append(tableIdents, ident)
+ }
+
+ return tableIdents, nil
+}
+
+func classifyNoSuchNamespace(ctx context.Context, listErr error,
namespaceIdent table.Identifier, checkExists namespaceExister) error {
+ exists, checkErr := checkExists.CheckNamespaceExists(ctx,
namespaceIdent)
+ if checkErr == nil && !exists {
+ return fmt.Errorf("%w: %s", errNamespaceNotFound,
identifierString(namespaceIdent))
+ }
+
+ return listErr
+}
+
+func (d *icebergTablesDataSource) Read(ctx context.Context, req
datasource.ReadRequest, resp *datasource.ReadResponse) {
+ tflog.Info(ctx, "Reading iceberg_tables data source")
+ d.configureCatalog(ctx, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ var data icebergTablesDataSourceModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if d.catalog == nil {
+ resp.Diagnostics.AddError(
+ "Catalog not available",
+ "The catalog could not be created (is catalog_uri
set?).",
+ )
+
+ return
+ }
+
+ var namespaceName []string
+ resp.Diagnostics.Append(data.Namespace.ElementsAs(ctx, &namespaceName,
false)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if len(namespaceName) == 0 {
+ resp.Diagnostics.AddError(
+ "Invalid namespace",
+ "The namespace attribute must contain at least one
namespace segment.",
+ )
+
+ return
+ }
+
+ namespaceIdent := catalog.ToIdentifier(namespaceName...)
+
+ tableIdents, err := collectListedTables(ctx, d.catalog.ListTables(ctx,
namespaceIdent), namespaceIdent, d.catalog)
+ if err != nil {
+ if errors.Is(err, errNamespaceNotFound) {
+ resp.Diagnostics.AddError(
+ "Namespace not found",
+ "No such namespace:
"+identifierString(namespaceIdent),
+ )
+
+ return
+ }
+ resp.Diagnostics.AddError("failed to list tables", err.Error())
+
+ return
+ }
+
+ sortTableIdentifiers(tableIdents)
+ tableNames := tableNamesFromIdentifiers(tableIdents)
+ identifierStrings := tableIdentifierStrings(tableIdents)
+
+ tables, diags := types.ListValueFrom(ctx, types.StringType, tableNames)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ identifiers, diags := types.ListValueFrom(ctx, types.StringType,
identifierStrings)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ data.ID = types.StringValue(identifierString(namespaceIdent))
+ data.Tables = tables
+ data.Identifiers = identifiers
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
diff --git a/internal/provider/data_source_tables_test.go
b/internal/provider/data_source_tables_test.go
new file mode 100644
index 0000000..7942a68
--- /dev/null
+++ b/internal/provider/data_source_tables_test.go
@@ -0,0 +1,520 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package provider
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "iter"
+ "os"
+ "regexp"
+ "testing"
+
+ "github.com/apache/iceberg-go/catalog"
+ "github.com/apache/iceberg-go/table"
+ "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIdentifierString(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "db.events", identifierString(table.Identifier{"db",
"events"}))
+ assert.Equal(t, "analytics.raw.orders",
identifierString(table.Identifier{"analytics", "raw", "orders"}))
+ assert.Equal(t, "", identifierString(nil))
+}
+
+func TestSortTableIdentifiers(t *testing.T) {
+ t.Parallel()
+
+ ids := []table.Identifier{
+ {"analytics", "raw", "orders"},
+ {"analytics", "raw", "events"},
+ {"ns", "b"},
+ {"ns", "a"},
+ {"ns", "a"},
+ }
+ sortTableIdentifiers(ids)
+
+ assert.Equal(t, []table.Identifier{
+ {"analytics", "raw", "events"},
+ {"analytics", "raw", "orders"},
+ {"ns", "a"},
+ {"ns", "a"},
+ {"ns", "b"},
+ }, ids)
+}
+
+func TestTableNamesFromIdentifiers(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ids []table.Identifier
+ want []string
+ }{
+ {
+ name: "empty",
+ ids: nil,
+ want: []string{},
+ },
+ {
+ name: "single table in flat namespace",
+ ids: []table.Identifier{{"db", "events"}},
+ want: []string{"events"},
+ },
+ {
+ name: "preserves caller order",
+ ids: []table.Identifier{
+ {"analytics", "raw", "orders"},
+ {"analytics", "raw", "events"},
+ },
+ want: []string{"orders", "events"},
+ },
+ {
+ name: "nested namespace",
+ ids: []table.Identifier{{"analytics", "prod",
"metrics"}},
+ want: []string{"metrics"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want,
tableNamesFromIdentifiers(tt.ids))
+ })
+ }
+}
+
+func TestTableIdentifierStrings(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ids []table.Identifier
+ want []string
+ }{
+ {
+ name: "empty",
+ ids: nil,
+ want: []string{},
+ },
+ {
+ name: "single table in flat namespace",
+ ids: []table.Identifier{{"db", "events"}},
+ want: []string{"db.events"},
+ },
+ {
+ name: "preserves caller order",
+ ids: []table.Identifier{
+ {"analytics", "raw", "orders"},
+ {"analytics", "raw", "events"},
+ },
+ want: []string{"analytics.raw.orders",
"analytics.raw.events"},
+ },
+ {
+ name: "nested namespace",
+ ids: []table.Identifier{{"analytics", "prod",
"metrics"}},
+ want: []string{"analytics.prod.metrics"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, tableIdentifierStrings(tt.ids))
+ })
+ }
+}
+
+func TestSortedTableListOutputs(t *testing.T) {
+ t.Parallel()
+
+ ids := []table.Identifier{
+ {"analytics", "raw", "orders"},
+ {"analytics", "raw", "events"},
+ }
+ sortTableIdentifiers(ids)
+
+ assert.Equal(t, []string{"events", "orders"},
tableNamesFromIdentifiers(ids))
+ assert.Equal(t, []string{"analytics.raw.events",
"analytics.raw.orders"}, tableIdentifierStrings(ids))
+}
+
+func listTablesSeq(yields []table.Identifier, finalErr error)
iter.Seq2[table.Identifier, error] {
+ return func(yield func(table.Identifier, error) bool) {
+ for _, ident := range yields {
+ if !yield(ident, nil) {
+ return
+ }
+ }
+ if finalErr != nil {
+ yield(table.Identifier{}, finalErr)
+ }
+ }
+}
+
+type stubNamespaceExister struct {
+ exists bool
+ err error
+}
+
+func (s stubNamespaceExister) CheckNamespaceExists(context.Context,
table.Identifier) (bool, error) {
+ return s.exists, s.err
+}
+
+type unexpectedNamespaceExister struct {
+ t *testing.T
+}
+
+func (s unexpectedNamespaceExister) CheckNamespaceExists(context.Context,
table.Identifier) (bool, error) {
+ s.t.Helper()
+ s.t.Fatal("CheckNamespaceExists should not be called")
+
+ return false, nil
+}
+
+func TestCollectListedTables(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ ns := table.Identifier{"analytics", "raw"}
+
+ t.Run("success", func(t *testing.T) {
+ t.Parallel()
+
+ got, err := collectListedTables(ctx,
listTablesSeq([]table.Identifier{
+ {"analytics", "raw", "orders"},
+ {"analytics", "raw", "events"},
+ }, nil), ns, unexpectedNamespaceExister{t})
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Equal(t, []table.Identifier{
+ {"analytics", "raw", "orders"},
+ {"analytics", "raw", "events"},
+ }, got)
+ })
+
+ t.Run("empty results", func(t *testing.T) {
+ t.Parallel()
+
+ got, err := collectListedTables(ctx, listTablesSeq(nil, nil),
ns, unexpectedNamespaceExister{t})
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Empty(t, got)
+ })
+
+ t.Run("first yield ErrNoSuchNamespace", func(t *testing.T) {
+ t.Parallel()
+
+ got, err := collectListedTables(ctx, listTablesSeq(nil,
catalog.ErrNoSuchNamespace), ns, stubNamespaceExister{})
+ assert.Nil(t, got)
+ if !assert.Error(t, err) {
+ return
+ }
+ assert.ErrorIs(t, err, errNamespaceNotFound)
+ assert.Contains(t, err.Error(), "analytics.raw")
+ // Catalog sentinel is intentionally not wrapped here; Read
keys off
+ // errNamespaceNotFound so mid-pagination ErrNoSuchNamespace
stays distinct.
+ assert.False(t, errors.Is(err, catalog.ErrNoSuchNamespace))
+ })
+
+ t.Run("generic error", func(t *testing.T) {
+ t.Parallel()
+
+ wantErr := errors.New("catalog unavailable")
+ got, err := collectListedTables(ctx, listTablesSeq(nil,
wantErr), ns, unexpectedNamespaceExister{t})
+ assert.Nil(t, got)
+ assert.ErrorIs(t, err, wantErr)
+ })
+
+ t.Run("error after partial results preserves cause", func(t *testing.T)
{
+ t.Parallel()
+
+ // Mimics iceberg-go REST pagination: identifiers from page 1,
then a
+ // page-level HTTP 404 wrapped as ErrNoSuchNamespace (e.g. bad
page token).
+ pageErr := fmt.Errorf("NoSuchPageTokenException: %w",
catalog.ErrNoSuchNamespace)
+ got, err := collectListedTables(ctx,
listTablesSeq([]table.Identifier{
+ {"analytics", "raw", "events"},
+ {"analytics", "raw", "orders"},
+ }, pageErr), ns, stubNamespaceExister{exists: true})
+ assert.Nil(t, got)
+ if !assert.Error(t, err) {
+ return
+ }
+ assert.ErrorIs(t, err, catalog.ErrNoSuchNamespace)
+ assert.False(t, errors.Is(err, errNamespaceNotFound))
+ assert.Contains(t, err.Error(), "NoSuchPageTokenException")
+ })
+
+ t.Run("empty first page then pagination 404 is not missing namespace",
func(t *testing.T) {
+ t.Parallel()
+
+ // Empty non-final page yields nothing, then a later page 404.
Yield
+ // count is still 0, but CheckNamespaceExists reports the
namespace
+ // is present — must not become errNamespaceNotFound.
+ pageErr := fmt.Errorf("NoSuchPageTokenException: %w",
catalog.ErrNoSuchNamespace)
+ got, err := collectListedTables(ctx, listTablesSeq(nil,
pageErr), ns, stubNamespaceExister{exists: true})
+ assert.Nil(t, got)
+ if !assert.Error(t, err) {
+ return
+ }
+ assert.ErrorIs(t, err, catalog.ErrNoSuchNamespace)
+ assert.False(t, errors.Is(err, errNamespaceNotFound))
+ assert.Contains(t, err.Error(), "NoSuchPageTokenException")
+ })
+
+ t.Run("existence probe error preserves list error", func(t *testing.T) {
+ t.Parallel()
+
+ pageErr := fmt.Errorf("NoSuchPageTokenException: %w",
catalog.ErrNoSuchNamespace)
+ got, err := collectListedTables(ctx, listTablesSeq(nil,
pageErr), ns, stubNamespaceExister{
+ err: errors.New("HEAD failed"),
+ })
+ assert.Nil(t, got)
+ if !assert.Error(t, err) {
+ return
+ }
+ assert.ErrorIs(t, err, catalog.ErrNoSuchNamespace)
+ assert.False(t, errors.Is(err, errNamespaceNotFound))
+ assert.Contains(t, err.Error(), "NoSuchPageTokenException")
+ })
+
+ t.Run("rejects empty identifier", func(t *testing.T) {
+ t.Parallel()
+
+ got, err := collectListedTables(ctx,
listTablesSeq([]table.Identifier{{}}, nil), ns, unexpectedNamespaceExister{t})
+ assert.Nil(t, got)
+ if !assert.Error(t, err) {
+ return
+ }
+ assert.Contains(t, err.Error(), "empty table identifier")
+ })
+
+ t.Run("rejects identifier outside namespace", func(t *testing.T) {
+ t.Parallel()
+
+ got, err := collectListedTables(ctx,
listTablesSeq([]table.Identifier{
+ {"other", "ns", "events"},
+ }, nil), ns, unexpectedNamespaceExister{t})
+ assert.Nil(t, got)
+ if !assert.Error(t, err) {
+ return
+ }
+ assert.Contains(t, err.Error(), `outside requested namespace
"analytics.raw"`)
+ })
+}
+
+func TestAccIcebergTablesDataSource_Full(t *testing.T) {
+ catalogURI := os.Getenv("ICEBERG_CATALOG_URI")
+ if catalogURI == "" {
+ t.Skip("ICEBERG_CATALOG_URI not set, skipping tables data
source E2E test")
+ }
+
+ providerCfg := fmt.Sprintf(providerConfig, catalogURI)
+ suffix := acctest.RandString(8)
+ basicNS := "ns_tables_ds_basic_" + suffix
+ nestedParent := "analytics_" + suffix
+ emptyNS := "ns_tables_ds_empty_" + suffix
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config:
testAccIcebergTablesDataSourceBasicConfig(providerCfg, basicNS),
+ Check: resource.ComposeTestCheckFunc(
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "namespace.0",
basicNS),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "id", basicNS),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "tables.#", "2"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "tables.0",
"alpha_table"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "tables.1",
"beta_table"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "identifiers.#",
"2"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "identifiers.0",
basicNS+".alpha_table"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "identifiers.1",
basicNS+".beta_table"),
+ ),
+ },
+ {
+ Config:
testAccIcebergTablesDataSourceNestedConfig(providerCfg, nestedParent),
+ Check: resource.ComposeTestCheckFunc(
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "namespace.0",
nestedParent),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "namespace.1",
"raw"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "id",
nestedParent+".raw"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "tables.#", "1"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "tables.0",
"events"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "identifiers.#",
"1"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "identifiers.0",
nestedParent+".raw.events"),
+ ),
+ },
+ {
+ Config:
testAccIcebergTablesDataSourceEmptyNamespaceConfig(providerCfg, emptyNS),
+ Check: resource.ComposeTestCheckFunc(
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "namespace.0",
emptyNS),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "tables.#", "0"),
+
resource.TestCheckResourceAttr("data.iceberg_tables.read", "identifiers.#",
"0"),
+ ),
+ },
+ },
+ })
+}
+
+func TestAccIcebergTablesDataSource_NotFound(t *testing.T) {
+ catalogURI := os.Getenv("ICEBERG_CATALOG_URI")
+ if catalogURI == "" {
+ t.Skip("ICEBERG_CATALOG_URI not set, skipping tables data
source E2E test")
+ }
+
+ providerCfg := fmt.Sprintf(providerConfig, catalogURI)
+ missingNS := "ns_tables_ds_missing_" + acctest.RandString(8)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config:
testAccIcebergTablesDataSourceMissingConfig(providerCfg, missingNS),
+ ExpectError: regexp.MustCompile(`Namespace not
found`),
+ },
+ },
+ })
+}
+
+func TestAccIcebergTablesDataSource_EmptyNamespaceAttribute(t *testing.T) {
+ catalogURI := os.Getenv("ICEBERG_CATALOG_URI")
+ if catalogURI == "" {
+ t.Skip("ICEBERG_CATALOG_URI not set, skipping tables data
source E2E test")
+ }
+
+ providerCfg := fmt.Sprintf(providerConfig, catalogURI)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config:
testAccIcebergTablesDataSourceEmptyNamespaceAttributeConfig(providerCfg),
+ ExpectError: regexp.MustCompile(`(?s)Invalid
namespace.*at least one namespace segment`),
+ },
+ },
+ })
+}
+
+func testAccIcebergTablesDataSourceBasicConfig(providerCfg, namespace string)
string {
+ return providerCfg + fmt.Sprintf(`
+resource "iceberg_namespace" "db" {
+ name = [%q]
+}
+
+resource "iceberg_table" "alpha" {
+ namespace = iceberg_namespace.db.name
+ name = "alpha_table"
+ schema = {
+ fields = [
+ {
+ id = 1
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+resource "iceberg_table" "beta" {
+ namespace = iceberg_namespace.db.name
+ name = "beta_table"
+ schema = {
+ fields = [
+ {
+ id = 1
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+data "iceberg_tables" "read" {
+ namespace = iceberg_namespace.db.name
+
+ depends_on = [
+ iceberg_table.alpha,
+ iceberg_table.beta,
+ ]
+}
+`, namespace)
+}
+
+func testAccIcebergTablesDataSourceNestedConfig(providerCfg, parentNS string)
string {
+ return providerCfg + fmt.Sprintf(`
+resource "iceberg_namespace" "db" {
+ name = [%q, "raw"]
+}
+
+resource "iceberg_table" "events" {
+ namespace = iceberg_namespace.db.name
+ name = "events"
+ schema = {
+ fields = [
+ {
+ id = 1
+ name = "id"
+ type = "long"
+ required = true
+ }
+ ]
+ }
+}
+
+data "iceberg_tables" "read" {
+ namespace = iceberg_namespace.db.name
+
+ depends_on = [iceberg_table.events]
+}
+`, parentNS)
+}
+
+func testAccIcebergTablesDataSourceEmptyNamespaceConfig(providerCfg, namespace
string) string {
+ return providerCfg + fmt.Sprintf(`
+resource "iceberg_namespace" "db" {
+ name = [%q]
+}
+
+data "iceberg_tables" "read" {
+ namespace = iceberg_namespace.db.name
+}
+`, namespace)
+}
+
+func testAccIcebergTablesDataSourceEmptyNamespaceAttributeConfig(providerCfg
string) string {
+ return providerCfg + `
+data "iceberg_tables" "empty_attr" {
+ namespace = []
+}
+`
+}
+
+func testAccIcebergTablesDataSourceMissingConfig(providerCfg, namespace
string) string {
+ return providerCfg + fmt.Sprintf(`
+data "iceberg_tables" "missing" {
+ namespace = [%q]
+}
+`, namespace)
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 9bd8dee..ff8d555 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -394,6 +394,7 @@ func (p *icebergProvider) DataSources(_ context.Context)
[]func() datasource.Dat
return []func() datasource.DataSource{
NewNamespaceDataSource,
NewTableDataSource,
+ NewTablesDataSource,
}
}