Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"

Expand All @@ -27,7 +27,7 @@ jobs:
ls -lah dist

- name: Upload GitHub Release assets
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: |
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ Initial release.
- Cloudflare R2 Data Catalog setup guide
- PyIceberg CLI
- DuckDB attach SQL generator
- Explicit DuckDB `httpfs`, named secret, and nested namespace support
- Docker and Codespaces support
- GitHub Actions CI
- GitHub Actions release ZIP workflow
- Basic tests
- Release packaging that excludes local secrets and symlinks
- Unit tests for settings, sample data, DuckDB SQL, and release packaging
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Compute: 必要な時だけ local PC / GitHub Codespaces / DuckDB / Python

Cloudflare R2 Data Catalog は R2 bucket に組み込まれた managed Apache Iceberg catalog です。標準 Iceberg REST Catalog interface を公開するため、PyIceberg や DuckDB などから接続できます。

Data Catalog の catalog operation、compaction、R2 storage / operation には無料枠と従量課金があります。金額や無料枠は変更されるため、実行前に [R2 Data Catalog pricing](https://developers.cloudflare.com/r2/data-catalog/platform/pricing/) と [R2 pricing](https://developers.cloudflare.com/r2/pricing/) を確認してください。

## 前提

- Cloudflare account
Expand Down
8 changes: 7 additions & 1 deletion docs/duckdb-ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ LIMIT 20;
INSTALL iceberg;
LOAD iceberg;

INSTALL httpfs;
LOAD httpfs;

CREATE SECRET r2_iceberg_secret (
TYPE iceberg,
TOKEN '<ICEBERG_TOKEN>'
);

ATTACH '<ICEBERG_WAREHOUSE>' AS r2_iceberg (
TYPE iceberg,
ENDPOINT '<ICEBERG_CATALOG_URI>'
SECRET r2_iceberg_secret,
ENDPOINT '<ICEBERG_CATALOG_URI>',
SUPPORT_NESTED_NAMESPACES true
);
```

Expand All @@ -50,6 +55,7 @@ DuckDB の Iceberg extension を更新します。
```sql
UPDATE EXTENSIONS;
LOAD iceberg;
LOAD httpfs;
```

### token が漏れそう
Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting-ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ iceberg-r2-lab create

## Cost が心配

- [R2 Data Catalog pricing](https://developers.cloudflare.com/r2/data-catalog/platform/pricing/) と [R2 pricing](https://developers.cloudflare.com/r2/pricing/) で最新の無料枠と従量課金を確認する
- 大量 append しない
- sample data は数行にする
- 不要なら automatic compaction / snapshot expiration を有効にしない
- 検証後は table / catalog / bucket を cleanup する
- Codespaces は使い終わったら stop する
7 changes: 6 additions & 1 deletion examples/duckdb/attach_r2_template.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
INSTALL iceberg;
LOAD iceberg;

INSTALL httpfs;
LOAD httpfs;

CREATE SECRET r2_iceberg_secret (
TYPE iceberg,
TOKEN '<ICEBERG_TOKEN>'
);

ATTACH '<ICEBERG_WAREHOUSE>' AS r2_iceberg (
TYPE iceberg,
ENDPOINT '<ICEBERG_CATALOG_URI>'
SECRET r2_iceberg_secret,
ENDPOINT '<ICEBERG_CATALOG_URI>',
SUPPORT_NESTED_NAMESPACES true
);

CREATE SCHEMA IF NOT EXISTS r2_iceberg.demo;
Expand Down
4 changes: 3 additions & 1 deletion scripts/package_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def should_include(path: Path) -> bool:
return False
if path.name in EXCLUDE_FILES:
return False
if path.name.startswith(".env.") and path.name != ".env.example":
return False
if path.suffix in {".pyc", ".pyo"}:
return False
return True
Expand All @@ -43,7 +45,7 @@ def build_zip(root: Path, version: str) -> Path:

with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(root.rglob("*")):
if not path.is_file():
if path.is_symlink() or not path.is_file():
continue
rel = path.relative_to(root)
if not should_include(rel):
Expand Down
33 changes: 28 additions & 5 deletions src/iceberg_r2_lab/duckdb_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,26 @@ def sql_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"


def sql_identifier(value: str) -> str:
"""Quote a DuckDB identifier without treating it as SQL."""
if not value:
raise ValueError("DuckDB identifiers must not be empty.")

return '"' + value.replace('"', '""') + '"'


def qualified_identifier(*parts: str) -> str:
return ".".join(sql_identifier(part) for part in parts)


def generate_attach_sql(settings: Settings) -> str:
alias = settings.duckdb_catalog_alias
alias = sql_identifier(settings.duckdb_catalog_alias)
schema = qualified_identifier(settings.duckdb_catalog_alias, settings.namespace)
table = qualified_identifier(
settings.duckdb_catalog_alias,
settings.namespace,
settings.table,
)
warehouse = sql_quote(settings.warehouse)
endpoint = sql_quote(settings.catalog_uri)
token = sql_quote(settings.token)
Expand All @@ -20,19 +38,24 @@ def generate_attach_sql(settings: Settings) -> str:
INSTALL iceberg;
LOAD iceberg;

INSTALL httpfs;
LOAD httpfs;

CREATE SECRET r2_iceberg_secret (
TYPE iceberg,
TOKEN {token}
);

ATTACH {warehouse} AS {alias} (
TYPE iceberg,
ENDPOINT {endpoint}
SECRET r2_iceberg_secret,
ENDPOINT {endpoint},
SUPPORT_NESTED_NAMESPACES true
);

CREATE SCHEMA IF NOT EXISTS {alias}.{settings.namespace};
USE {alias}.{settings.namespace};
CREATE SCHEMA IF NOT EXISTS {schema};
USE {schema};

-- Example:
-- SELECT * FROM {alias}.{settings.namespace}.{settings.table} LIMIT 20;
-- SELECT * FROM {table} LIMIT 20;
"""
35 changes: 33 additions & 2 deletions tests/test_duckdb_sql.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from iceberg_r2_lab.duckdb_sql import generate_attach_sql
import pytest

from iceberg_r2_lab.duckdb_sql import generate_attach_sql, sql_identifier
from iceberg_r2_lab.settings import Settings


Expand All @@ -13,4 +15,33 @@ def test_generate_attach_sql_escapes_token():

assert "CREATE SECRET" in sql
assert "abc''def" in sql
assert "ATTACH 'warehouse' AS r2_iceberg" in sql
assert "INSTALL httpfs;" in sql
assert "LOAD httpfs;" in sql
assert "ATTACH 'warehouse' AS \"r2_iceberg\"" in sql
assert "SECRET r2_iceberg_secret" in sql


def test_generate_attach_sql_quotes_identifiers():
settings = Settings(
catalog_uri="https://example.com/catalog",
warehouse="warehouse",
token="token",
namespace='team.analytics"daily',
table="people-import",
duckdb_catalog_alias="r2-catalog",
)

sql = generate_attach_sql(settings)

assert 'CREATE SCHEMA IF NOT EXISTS "r2-catalog"."team.analytics""daily";' in sql
assert 'USE "r2-catalog"."team.analytics""daily";' in sql
assert (
'-- SELECT * FROM "r2-catalog"."team.analytics""daily"."people-import" LIMIT 20;'
in sql
)
assert "SUPPORT_NESTED_NAMESPACES true" in sql


def test_sql_identifier_rejects_an_empty_value():
with pytest.raises(ValueError, match="must not be empty"):
sql_identifier("")
37 changes: 37 additions & 0 deletions tests/test_package_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from pathlib import Path
import runpy
import zipfile

import pytest


SCRIPT = Path(__file__).parents[1] / "scripts" / "package_release.py"
build_zip = runpy.run_path(str(SCRIPT), run_name="package_release")["build_zip"]


def archived_paths(zip_path: Path) -> set[str]:
with zipfile.ZipFile(zip_path) as archive:
return set(archive.namelist())


def test_build_zip_excludes_local_secrets_and_symlinks(tmp_path):
(tmp_path / "README.md").write_text("lab", encoding="utf-8")
(tmp_path / ".env.example").write_text("TOKEN=", encoding="utf-8")
(tmp_path / ".env").write_text("TOKEN=secret", encoding="utf-8")
(tmp_path / ".env.local").write_text("TOKEN=local-secret", encoding="utf-8")

try:
(tmp_path / "linked-secret").symlink_to(tmp_path / ".env.local")
except OSError as error:
pytest.skip(f"Symlink creation is not available: {error}")

zip_path = build_zip(tmp_path, "1.2.3")
paths = archived_paths(zip_path)
prefix = "iceberg-r2-online-lab-v1.2.3"

assert f"{prefix}/README.md" in paths
assert f"{prefix}/.env.example" in paths
assert f"{prefix}/.env" not in paths
assert f"{prefix}/.env.local" not in paths
assert f"{prefix}/linked-secret" not in paths
assert zip_path.with_suffix(".zip.sha256").is_file()