Skip to content

Preserve column-level OPTIONS in CREATE FOREIGN TABLE#6

Merged
gmr merged 1 commit intomainfrom
fix/foreign-table-column-options
Apr 17, 2026
Merged

Preserve column-level OPTIONS in CREATE FOREIGN TABLE#6
gmr merged 1 commit intomainfrom
fix/foreign-table-column-options

Conversation

@gmr
Copy link
Copy Markdown
Owner

@gmr gmr commented Apr 17, 2026

Summary

  • CREATE FOREIGN TABLE silently dropped column-level OPTIONS (...) clauses — e.g. bar INTEGER OPTIONS (column_name 'Bar') NOT NULL lost the OPTIONS.
  • Root cause: the columnDef formatter read ColId, Typename, and ColQualList but ignored the sibling create_generic_options node that tree-sitter produces.
  • Both the river (aligned) and left-aligned code paths now emit the OPTIONS clause inline between the type and column constraints.
  • Extracted a shared collect_generic_options helper so the inline and multi-line OPTIONS formatters share list-extraction logic.

Fixes #5

Test plan

  • New fixture tests/fixtures/river/create_foreign_table_column_options.{sql,expected} covers the bug report.
  • All 66 fixtures pass (cargo test --test all_fixtures)
  • Unit and doc tests pass (cargo test)
  • cargo clippy -- -D warnings clean
  • cargo fmt --check clean

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced SQL formatter to properly handle and display column options in CREATE TABLE and CREATE FOREIGN TABLE statements with OPTIONS clauses.
  • Tests

    • Added test fixtures for CREATE FOREIGN TABLE with column options to verify correct formatting.

The columnDef path read ColId/Typename/ColQualList but ignored the
sibling create_generic_options node, silently dropping column OPTIONS
such as `bar INTEGER OPTIONS (column_name 'Bar') NOT NULL`. Both the
river (aligned) and left-aligned code paths now emit the OPTIONS clause
inline between the type and column constraints.

Extracted a shared collect_generic_options helper so the inline and
multi-line OPTIONS formatters reuse the same list-extraction logic.

Fixes #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The pull request adds support for formatting column-level OPTIONS clauses in CREATE FOREIGN TABLE statements, which were previously being dropped during formatting. This includes refactoring generic option rendering logic and introducing helper functions to handle both table-level and column-level OPTIONS across multiple formatting paths.

Changes

Cohort / File(s) Summary
Formatter Logic
src/formatter/stmt.rs
Added support for create_generic_options in column definitions through refactored helper functions (collect_generic_options, format_col_generic_options_inline) and updated classify_table_element and format_column_def to preserve column-level OPTIONS during formatting.
Test Fixtures
tests/fixtures/river/create_foreign_table_column_options.sql, tests/fixtures/river/create_foreign_table_column_options.expected
New test fixtures validating CREATE FOREIGN TABLE formatting with column-level OPTIONS (e.g., OPTIONS (column_name 'Bar') on columns) alongside table-level OPTIONS.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

Poem

🐰 A hop through the parser, so neat and so clean,
Column OPTIONS now visible, no longer unseen,
The formatter once dropped them with careless abandon,
But now they persist in each foreign-table command, son!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: preserving column-level OPTIONS in CREATE FOREIGN TABLE statements, which directly aligns with the changeset's primary objective.
Linked Issues check ✅ Passed The pull request fully implements the requirements from issue #5 by adding support for column-level OPTIONS formatting in CREATE FOREIGN TABLE statements and provides test fixtures to verify the fix.
Out of Scope Changes check ✅ Passed All changes are directly related to the stated objective of preserving column-level OPTIONS in CREATE FOREIGN TABLE; no out-of-scope modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/foreign-table-column-options

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/formatter/stmt.rs (2)

967-970: Guard against empty options in inline rendering.

format_col_generic_options_inline will emit OPTIONS () if the create_generic_options node has no generic_option_list child (or an empty one). The callers already only invoke this when create_generic_options exists, but tree-sitter can produce a stub node on partial/ERROR input. Consider returning an empty string when items is empty, mirroring the early-return in format_generic_options.

♻️ Suggested change
 fn format_col_generic_options_inline(&self, node: Node<'a>) -> String {
     let items = self.collect_generic_options(node);
+    if items.is_empty() {
+        return String::new();
+    }
     format!("{} ({})", self.kw("OPTIONS"), items.join(", "))
 }

Callers would then also want to skip pushing an empty string into the parts list to avoid a stray extra space — or keep the push since parts.join(" ") handles empties but yields a double-space. Simpler: have callers check is_empty() before pushing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/formatter/stmt.rs` around lines 967 - 970,
format_col_generic_options_inline can produce "OPTIONS ()" when
collect_generic_options returns an empty vector; change
format_col_generic_options_inline to return an empty string when
items.is_empty() instead of formatting "OPTIONS ({})", and mirror the
early-return behavior of format_generic_options. Update callers that push its
return into parts (where parts.join(" ") is used) to skip pushing when the
returned string is empty (or call .is_empty() before pushing) so no stray extra
spaces are produced. Use the symbols format_col_generic_options_inline,
collect_generic_options, format_generic_options, and the callers that push into
parts to locate and apply the change.

414-416: Column OPTIONS now renders in regular CREATE TABLE due to shared classify_table_element logic.

classify_table_element is shared between format_create_table_stmt (line 276) and format_create_foreign_table_stmt (line 833), so regular table columns with OPTIONS(...) will now render the clause alongside other constraints. The alignment mechanism is correct—max_type_len determines column width, and trailing OPTIONS(...) NOT NULL flows into the constraints tail as intended.

However, no fixture currently tests regular CREATE TABLE with column OPTIONS. The existing fixtures only cover foreign tables. Consider adding a test case to confirm the non-foreign output aligns properly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/formatter/stmt.rs` around lines 414 - 416, The shared
classify_table_element logic causes regular CREATE TABLE columns with
OPTIONS(...) to render the OPTIONS clause with other constraints (via
format_col_generic_options_inline), so add a unit/fixture that covers a regular
CREATE TABLE containing a column with OPTIONS(...) (not just foreign tables) to
verify alignment and output; update or add a test similar to the existing
foreign-table fixture but targeting format_create_table_stmt (and exercise
classify_table_element and format_col_generic_options_inline) to assert the
OPTIONS clause appears in the constraints tail and column alignment governed by
max_type_len remains correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/formatter/stmt.rs`:
- Around line 967-970: format_col_generic_options_inline can produce "OPTIONS
()" when collect_generic_options returns an empty vector; change
format_col_generic_options_inline to return an empty string when
items.is_empty() instead of formatting "OPTIONS ({})", and mirror the
early-return behavior of format_generic_options. Update callers that push its
return into parts (where parts.join(" ") is used) to skip pushing when the
returned string is empty (or call .is_empty() before pushing) so no stray extra
spaces are produced. Use the symbols format_col_generic_options_inline,
collect_generic_options, format_generic_options, and the callers that push into
parts to locate and apply the change.
- Around line 414-416: The shared classify_table_element logic causes regular
CREATE TABLE columns with OPTIONS(...) to render the OPTIONS clause with other
constraints (via format_col_generic_options_inline), so add a unit/fixture that
covers a regular CREATE TABLE containing a column with OPTIONS(...) (not just
foreign tables) to verify alignment and output; update or add a test similar to
the existing foreign-table fixture but targeting format_create_table_stmt (and
exercise classify_table_element and format_col_generic_options_inline) to assert
the OPTIONS clause appears in the constraints tail and column alignment governed
by max_type_len remains correct.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19d75111-eda6-4ef6-89f9-2267d9720b9f

📥 Commits

Reviewing files that changed from the base of the PR and between 4f8d8d1 and 7cf948a.

📒 Files selected for processing (3)
  • src/formatter/stmt.rs
  • tests/fixtures/river/create_foreign_table_column_options.expected
  • tests/fixtures/river/create_foreign_table_column_options.sql

@gmr
Copy link
Copy Markdown
Owner Author

gmr commented Apr 17, 2026

Thanks @coderabbitai — evaluated both nitpicks and declining both on merit:

1. Empty-options guard in format_col_generic_options_inline (lines 967-970)
The callers only invoke this when create_generic_options exists, as CodeRabbit notes. The hypothetical stub-node case from tree-sitter ERROR input would be rejected upstream — per this project's error-tolerance policy, only sub-5-byte ERROR nodes are tolerated, and structural errors (which this would be) cause format() to return an error. Adding a guard here would be defensive handling for an impossible case, which this project explicitly avoids.

2. Fixture for regular CREATE TABLE with column OPTIONS (lines 414-416)
Column-level OPTIONS(...) is not valid PostgreSQL syntax on regular CREATE TABLE — it only exists in the CREATE FOREIGN TABLE grammar (per the SQL/MED spec and PostgreSQL's parser). tree-sitter-postgres follows the same grammar. A fixture with that shape wouldn't parse, so there's nothing meaningful to test. The shared classify_table_element path is correct because the grammar prevents invalid inputs from reaching it.

Both comments are unactionable; not making changes.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

Tip

For best results, initiate chat on the files or code changes.

@gmr, thanks for the thorough explanations — both make complete sense.

On point 1, this is consistent with the project's established error-tolerance policy (reject structural errors upstream, tolerate only sub-5-byte ERROR nodes), so defensive guarding here is indeed redundant.

On point 2, that's a good clarification about the PostgreSQL grammar — OPTIONS(...) at the column level is specific to the SQL/MED foreign table syntax and has no meaning in a regular CREATE TABLE. I'll record that for future reference.

ᓚᘏᗢ


✏️ Learnings added
Learnt from: gmr
URL: https://github.com/gmr/libpgfmt/pull/6

Timestamp: 2026-04-17T22:11:06.776Z
Learning: In the libpgfmt Rust crate (gmr/libpgfmt), column-level `OPTIONS(...)` clauses are not valid PostgreSQL syntax for regular `CREATE TABLE` statements. They only exist in the `CREATE FOREIGN TABLE` grammar as defined by the SQL/MED specification and PostgreSQL's parser. tree-sitter-postgres follows the same grammar. Do not suggest fixtures or handling for column-level OPTIONS on regular CREATE TABLE.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: gmr
Repo: gmr/libpgfmt PR: 1
File: tests/fixtures/river/create_matview.sql:1-1
Timestamp: 2026-03-28T23:34:52.933Z
Learning: In the `libpgfmt` Rust crate (repository: gmr/libpgfmt), dropping user-supplied parentheses in WHERE clauses (and other boolean expressions) is intentional — it matches the reference behavior of the Python pgfmt project. Preserving original grouping parentheses is a planned future improvement that tree-sitter-postgres makes possible. Do not flag parentheses being dropped in formatted SQL output as a bug.

Learnt from: CR
Repo: gmr/libpgfmt PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T13:54:37.111Z
Learning: Applies to src/**/*.rs : Use tree-sitter-postgres for parsing both SQL (LANGUAGE) and PL/pgSQL (LANGUAGE_PLPGSQL) in libpgfmt

Learnt from: gmr
Repo: gmr/libpgfmt PR: 1
File: tests/fixtures/river/create_matview.expected:26-27
Timestamp: 2026-03-28T23:34:54.102Z
Learning: In the libpgfmt Rust library (gmr/libpgfmt), the formatter intentionally drops parentheses that alter AND/OR operator precedence in WHERE clauses (e.g., `(a IS NULL OR a > X) AND b = Y` becomes `a IS NULL OR a > X AND b = Y`). This is by design to match the Python pgfmt reference implementation, which uses pgparse/libpg_query and normalizes away such parentheses at the AST level before formatting. Since libpgfmt uses tree-sitter-postgres (concrete syntax tree), preserving these parentheses is technically feasible and is tracked as a known limitation for a future iteration.

Learnt from: CR
Repo: gmr/libpgfmt PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T13:54:37.111Z
Learning: Applies to src/**/*.rs : Tolerate small tree-sitter ERROR nodes (< 5 bytes, e.g., decimal fractions) but reject structural errors

Learnt from: gmr
Repo: gmr/libpgfmt PR: 1
File: tests/fixtures/river/insert_values.sql:1-1
Timestamp: 2026-03-28T23:34:55.276Z
Learning: The test fixtures in `tests/fixtures/` for the `libpgfmt` Rust crate (gmr/libpgfmt) are copied directly from the Python pgfmt test suite, which uses Simon Holywell's SQL Style Guide examples. The data values (e.g., album titles, dates) are intentional placeholders and should not be flagged for semantic inconsistencies.

@gmr gmr merged commit 350dcbb into main Apr 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CREATE FOREIGN TABLE dropping OPTIONS

1 participant