diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a4594..36a0089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,88 +1,107 @@ -# Changelog +# 更新紀錄(Changelog) -All notable changes to this project are documented in this file. +本檔記錄此專案所有值得注意的變更。 -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +格式參考 [Keep a Changelog](https://keepachangelog.com/zh-TW/1.1.0/),版本號遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/)。 ## [Unreleased] ### Added -- `CHANGELOG.md`. -- Full `README.md`: overview, requirements, installation, DSL usage, development and release flow. -- CLAUDE.md rule: every PR must also update `README.md` and `CHANGELOG.md`. +- 新增 `CHANGELOG.md`。 +- 完整的 `README.md`:概述、環境需求、安裝、DSL 用法、開發與發版流程。 +- CLAUDE.md 規則:每個 PR 都必須同步更新 `README.md` 與 `CHANGELOG.md`;`README.md` 與 `CHANGELOG.md` 一律用繁體中文撰寫。 + +### Changed + +- `README.md`、`CHANGELOG.md` 改以繁體中文(zh-TW)撰寫。 + +## [1.3.0] - 2026-10-27 + +### Fixed + +- `ExportTo::Base#each` 每次呼叫都會 raise `NoMethodError (undefined method 'rows!')`。現在會回傳所有列組成的 Array(第一列是標題列,內容與 `#to_csv` 相同);帶 block 時會逐列 yield 並回傳 exporter 本身,因此 Enumerable 方法(`map`、`to_a` 等)也能使用。`#each!` 沒有變動。 +- `TESTING.md` 與 `bin/test_all` 已過時(只涵蓋 Ruby 2.7–3.1)。現在它們的說明與執行內容都與 `.github/workflows/ci.yml` 的矩陣一致。 + +### Deprecated + +- `ExportTo::Base#to_xls`:自 1.0.0 起它回傳的就是 **XLSX** 資料(與 `#to_xlsx` 相同),而不是舊版 `.xls` 檔。為了不讓既有呼叫端壞掉,回傳值維持不變,但現在會印出一次性的 `[DEPRECATION]` 警告(輸出到 `$stderr`,每個 process 一次)。請改用 `#to_xlsx`;若要真正的 `.xls`,請用 `ExportTo::Exporter::Xls.new(export).to_xls`。未來的 major 版本可能會讓 `#to_xls` 回傳真正的 `.xls` 檔,或將它移除。 + +### Added + +- 測試:`#each`、`#to_xls` 的相容性(仍回傳 XLSX)與其棄用警告、`#to_xlsx` / `ExportTo::Exporter::Xls` 的輸出格式。 ## [1.2.1] - 2026-10-27 ### Added -- Support for Rails 8.0 / 8.1 and Ruby 3.4 (`activesupport` is now allowed up to `< 8.2`; the `csv` gem is an explicit dependency; the `required_ruby_version` upper bound is removed; new `ruby_3.3_rails_8.*` / `ruby_3.4_rails_8.*` gemfiles and CI jobs). -- GitHub Actions `publish.yml`: a new version on `master` is published to RubyGems through Trusted Publishing. -- CLAUDE.md release rules. +- 支援 Rails 8.0 / 8.1 與 Ruby 3.4(`activesupport` 現在放寬到 `< 8.2`;`csv` gem 改為明確相依;移除 `required_ruby_version` 的上限;新增 `ruby_3.3_rails_8.*` / `ruby_3.4_rails_8.*` gemfile 與對應的 CI job)。 +- GitHub Actions `publish.yml`:新版本進到 `master` 時,透過 Trusted Publishing 自動發佈到 RubyGems。 +- CLAUDE.md 加入發版規則。 ### Changed -- Removed stale per-gemfile lockfiles that broke older Rubies in CI. +- 移除每個 gemfile 各自的舊 lockfile,這些 lockfile 會讓 CI 在較舊的 Ruby 上失敗。 ## [1.2.0] - 2026-01-08 ### Added -- Ruby 3.2 and 3.3 test gemfiles (`ruby_3.2_rails_7.0`, `ruby_3.2_rails_7.1`, `ruby_3.3_rails_7.1`) and CI jobs. +- 新增 Ruby 3.2 與 3.3 的測試 gemfile(`ruby_3.2_rails_7.0`、`ruby_3.2_rails_7.1`、`ruby_3.3_rails_7.1`)與 CI job。 ### Changed -- Gemspec `required_ruby_version` upper bound raised from `< 3.2.0` to `< 3.4.0`. +- gemspec 的 `required_ruby_version` 上限由 `< 3.2.0` 提高到 `< 3.4.0`。 ## [1.1.2] - 2026-01-07 ### Changed -- Updated gemfile lockfiles. +- 更新各 gemfile 的 lockfile。 ## [1.1.1] - 2026-01-07 ### Added -- Multi-version Ruby / Rails test setup: per-version gemfiles, GitHub Actions matrix, `bin/test_all`, `bin/check_dependencies` and `TESTING.md`. +- 多版本 Ruby / Rails 測試環境:每個版本各自的 gemfile、GitHub Actions 矩陣、`bin/test_all`、`bin/check_dependencies` 與 `TESTING.md`。 ### Fixed -- A test no longer depends on a hard-coded macOS path. +- 測試不再依賴寫死的 macOS 路徑。 ## [1.1.0] - 2023-02-02 ### Changed -- XLSX exporter fixes and tweaks (`ExportTo::Exporter::Xlsx`). +- 修正並微調 XLSX exporter(`ExportTo::Exporter::Xlsx`)。 ## [1.0.0] - 2023-02-02 ### Changed -- Rewrite of the internals: export logic moved into `ExportTo::Exporter::Csv` / `Xls` / `Xlsx`; `ExportTo::Base` exposes `to_csv`, `to_xlsx` and `to_xls`. -- `activesupport` is now constrained to `>= 6, < 7`; development dependencies relaxed to `bundler >= 2.0`, `rake >= 13`. +- 內部改寫:匯出邏輯移到 `ExportTo::Exporter::Csv` / `Xls` / `Xlsx`;`ExportTo::Base` 提供 `to_csv`、`to_xlsx` 與 `to_xls`。 +- `activesupport` 限制為 `>= 6, < 7`;開發相依套件放寬為 `bundler >= 2.0`、`rake >= 13`。 ## [0.1.2] - 2019-06-19 ### Changed -- CSV output now force-quotes every field. +- CSV 輸出現在會強制幫每個欄位加上引號。 ## [0.1.1] - 2018-12-05 ### Fixed -- "File exists" bug when exporting. +- 修正匯出時出現 "File exists" 的 bug。 ## [0.1.0] - 2018-10-21 ### Added -- Initial release: `ExportTo::Base` DSL (`set`, `joins`, `presenter`, `each_with`), presenters with row counters, empty join relations, `to_xlsx`. +- 首次發佈:`ExportTo::Base` DSL(`set`、`joins`、`presenter`、`each_with`)、帶有列計數器的 presenter、空的 join relation 處理,以及 `to_xlsx`。 -[Unreleased]: https://github.com/superlanding/export_to/compare/v1.2.1...HEAD +[Unreleased]: https://github.com/superlanding/export_to/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/superlanding/export_to/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/superlanding/export_to/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/superlanding/export_to/compare/v1.1.2...v1.2.0 [1.1.2]: https://github.com/superlanding/export_to/compare/v1.1.1...v1.1.2 diff --git a/CLAUDE.md b/CLAUDE.md index 7004c75..1b7e994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ - **每一個 PR 都必須同步更新文件**: - 只要行為、用法、設定、相依套件或支援的 Ruby / Rails 版本有變,就要更新 `README.md`。 - 一律要在 `CHANGELOG.md` 新增一筆條目([Keep a Changelog](https://keepachangelog.com/zh-TW/1.1.0/) 格式,分類用 Added / Changed / Deprecated / Removed / Fixed / Security)。 +- **語言規則**:`README.md` 與 `CHANGELOG.md` 一律用繁體中文(zh-TW,台灣用語)撰寫;程式碼、識別字(class / 方法 / 變數名稱)、指令、檔名與路徑維持英文,不要翻譯。 - `CHANGELOG.md` 的寫法: - 沒有跳版號的 PR:寫在最上方的 `## [Unreleased]` 底下。 - 有跳版號的 PR:把條目寫在新的版本章節 `## [x.y.z] - YYYY-MM-DD`(日期為合併日),並同步更新檔尾的版本比較連結。 diff --git a/Gemfile.lock b/Gemfile.lock index 3f7cff6..bb3086b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - export_to (1.2.1) + export_to (1.3.0) activesupport (>= 6.0, < 8.2) csv fast_excel (~> 0.4) diff --git a/README.md b/README.md index b569484..2ff46db 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ [![Gem Version](https://badge.fury.io/rb/export_to.svg)](https://rubygems.org/gems/export_to) [![CI](https://github.com/superlanding/export_to/actions/workflows/ci.yml/badge.svg)](https://github.com/superlanding/export_to/actions/workflows/ci.yml) -Export a collection of records (ActiveRecord relations, arrays of Structs, anything that responds to `each`) to **CSV**, **XLSX** or **XLS** with a small declarative DSL. +用一套簡單的宣告式 DSL,把一批資料(ActiveRecord relation、Struct 陣列,或任何有 `each` 的物件)匯出成 **CSV** 或 **XLSX**(透過較底層的 exporter,也能匯出舊版 **XLS**)。 -You describe the columns once (title, source attribute, width, format, value transform) in an `ExportTo::Base` subclass, optionally add a presenter for computed columns and a `joins` relation that expands one record into several rows, then call `to_csv` / `to_xlsx` / `to_xls`. +你只要在 `ExportTo::Base` 的子類別裡宣告一次欄位(標題、來源屬性、寬度、格式、值的轉換),需要計算欄位時可以加上 presenter,也可以加上 `joins` relation 把一筆資料展開成多列,最後呼叫 `to_csv` / `to_xlsx` 即可。 -## Requirements +## 環境需求 -| Ruby | Rails / ActiveSupport tested in CI | +| Ruby | CI 測試的 Rails / ActiveSupport 版本 | |------|------------------------------------| | 2.7 | 6.1 | | 3.0 | 6.1, 7.0 | @@ -18,45 +18,45 @@ You describe the columns once (title, source attribute, width, format, value tra | 3.3 | 7.1, 8.0, 8.1 | | 3.4 | 8.0, 8.1 | -The gemspec requires Ruby `>= 2.7.0` and `activesupport >= 6.0, < 8.2`. The source of truth is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +gemspec 要求 Ruby `>= 2.7.0` 與 `activesupport >= 6.0, < 8.2`。實際支援範圍以 [`.github/workflows/ci.yml`](.github/workflows/ci.yml) 為準。 -Runtime dependencies: `fast_excel` (XLSX), `spreadsheet` (XLS), `csv`, `iconv`, `activesupport`. +Runtime 相依套件:`fast_excel`(XLSX)、`spreadsheet`(XLS)、`csv`、`iconv`、`activesupport`。 -## Installation +## 安裝 -Add this line to your application's Gemfile: +在應用程式的 Gemfile 加入這一行: ```ruby gem "export_to" ``` -And then execute: +然後執行: ```bash bundle install ``` -Or install it yourself with `gem install export_to`. +或自行執行 `gem install export_to` 安裝。 -## Usage +## 使用方式 -### 1. Define an exporter +### 1. 定義 exporter ```ruby class UserExport < ExportTo::Base - # set
, , , &optional_transform + # set <標題>, , <欄位選項>, &可選的轉換 block set "Name", :full_name, width: 30 set "Mobile", :mobile, width: 20, format: { bold: true } set "Amount", :amount, width: 12 do |value| - value + 100 # the block receives the cell value and returns the final one + value + 100 # block 會收到儲存格的值,並回傳最終要輸出的值 end - set "Source", "web" # a String key is written as a fixed value in every row + set "Source", "web" # key 為 String 時,每一列都會寫入這個固定值 - excel worksheet: "Users", height: 24 # optional XLSX settings (worksheet name, row height) + excel worksheet: "Users", height: 24 # 可選的 XLSX 設定(工作表名稱、列高) - presenter do # optional: computed columns + presenter do # 可選:計算欄位 def full_name - "#{first_name} #{last_name}" # unknown methods are delegated to the record + "#{first_name} #{last_name}" # 找不到的方法會委派給該筆 record end def amount @@ -64,32 +64,34 @@ class UserExport < ExportTo::Base end end - joins :wallets # optional: one row per record in record.wallets + joins :wallets # 可選:record.wallets 裡每一筆各佔一列 end ``` -- **`set(title, key, options = {}, &block)`**: one column per call, in order. - - `key` as a **Symbol** is read from the presenter (which delegates to the record); `nil` becomes `""`. - - `key` as a **String** is used literally. - - XLSX `options`: `width:` (column width) and `format:` (a `FastExcel` format hash, e.g. `{ bold: true }`). - - The optional block post-processes the cell value. -- **`excel(worksheet: "Name", height: 20)`**: worksheet name (default `"Default"`) and data row height (default `20`). The header row is bold, centered and has a background color. -- **`presenter { ... }`**: defines `YourExport::Presenter` (a subclass of the parent exporter's presenter). Inside it you can use `model`, `relation`, `relation?`, `x` (record index) and `y` (index inside the `joins` relation). -- **`joins(:relation_name)`**: when `record.relation_name` is present, every element becomes its own row and `relation` is that element; the presenter first looks for a method on `relation`, then on `model`. Records with an empty relation produce a single row (`relation?` is `false`). -- **`each_with { |presenter| ... }`**: a hook called with each row's presenter before the columns are read. -- **`self.each_method = :find_in_batches`**: iterate with `records.find_in_batches` instead of `records.each`, for large ActiveRecord relations. +- **`set(title, key, options = {}, &block)`**:每次呼叫新增一個欄位,依呼叫順序排列。 + - `key` 為 **Symbol** 時,會從 presenter 讀取(presenter 會委派給 record);`nil` 會變成 `""`。 + - `key` 為 **String** 時,直接當作固定內容使用。 + - XLSX 專用的 `options`:`width:`(欄寬)與 `format:`(`FastExcel` 的 format hash,例如 `{ bold: true }`)。 + - 可選的 block 用來後處理儲存格的值。 +- **`excel(worksheet: "Name", height: 20)`**:工作表名稱(預設 `"Default"`)與資料列的列高(預設 `20`)。標題列為粗體、置中並帶有背景色。 +- **`presenter { ... }`**:定義 `YourExport::Presenter`(是上層 exporter 的 presenter 的子類別)。在裡面可以使用 `model`、`relation`、`relation?`、`x`(record 的索引)與 `y`(`joins` relation 內的索引)。 +- **`joins(:relation_name)`**:當 `record.relation_name` 有資料時,每個元素各自成為一列,且 `relation` 就是該元素;presenter 會先找 `relation` 上的方法,找不到再找 `model`。relation 為空的 record 會產生單獨一列(此時 `relation?` 為 `false`)。 +- **`each_with { |presenter| ... }`**:hook,會在讀取各欄位之前,以每一列的 presenter 呼叫一次。 +- **`self.each_method = :find_in_batches`**:改用 `records.find_in_batches` 而不是 `records.each` 來迭代,適合很大的 ActiveRecord relation。 -### 2. Export +### 2. 匯出 ```ruby -users = User.includes(:wallets).where(active: true) # or any enumerable +users = User.includes(:wallets).where(active: true) # 或任何可列舉的物件 export = UserExport.new(users) -export.to_csv # => String, every field quoted ("Name","Mobile",...) -export.to_xlsx # => binary String of an .xlsx file +export.to_csv # => String,每個欄位都有加引號("Name","Mobile",...) +export.to_xlsx # => .xlsx 檔案的二進位 String +export.each # => 所有列組成的 Array,第一列是標題列:[["Name", "Mobile", ...], ["Eddie Li", ...], ...] +export.each { |columns| ... } # 逐列 yield(第一列是標題列),並回傳 exporter 本身;Enumerable 方法也能用 ``` -Example CSV output for two users (the second has two wallets): +兩位使用者的 CSV 輸出範例(第二位有兩個 wallet): ``` "Name","Mobile","Amount","Source" @@ -98,7 +100,7 @@ Example CSV output for two users (the second has two wallets): "Vegeta Lu","+8868","2100","web" ``` -Serving it from a Rails controller: +在 Rails controller 裡提供下載: ```ruby def export @@ -108,48 +110,47 @@ def export end ``` -### Known limitations +### 已知限制 -These describe the current behavior of the code: +以下是目前程式碼的實際行為: -- `ExportTo::Base#to_xls` currently delegates to the **XLSX** exporter, so it returns xlsx data. For a real legacy `.xls` file use `ExportTo::Exporter::Xls.new(export).to_xls`. -- `ExportTo::Base#each` calls an undefined `rows!` and raises `NoMethodError`. Iterate with `each!` (yields `columns, record, index`, with the header row first) or use one of the `to_*` methods. -- `width:` / `format:` and `excel` options only affect XLSX output. -- Subclasses share the column definitions arrays of their parent class (`class_attribute`), so do not call `set` on a subclass of an exporter that is used on its own. +- `ExportTo::Base#to_xls` **已棄用,而且名稱有誤導性**:自 1.0.0 起它回傳的是與 `#to_xlsx` 相同的 **XLSX** 資料(不是舊版 `.xls` 檔)。保留這個行為是為了不讓既有呼叫端壞掉;呼叫時會印出一次性的 `[DEPRECATION]` 警告。請改用 `#to_xlsx`。若需要真正的舊版 `.xls` 檔,請用 `ExportTo::Exporter::Xls.new(export).to_xls`(不會套用任何樣式選項)。 +- `width:` / `format:` 與 `excel` 選項只對 XLSX 輸出有效。 +- 子類別會共用上層 class 的欄位定義陣列(`class_attribute`),所以如果某個 exporter 本身也會單獨使用,就不要在它的子類別裡呼叫 `set`。 -## Development +## 開發 -After checking out the repo, run `bin/setup` to install dependencies. Then run the tests: +clone 下來之後,先執行 `bin/setup` 安裝相依套件,接著跑測試: ```bash -bundle exec rake test # also: rake t / rake (default) -bin/console # interactive prompt +bundle exec rake test # 也可以用 rake t / rake(預設 task) +bin/console # 互動式 console ``` -Each Ruby / Rails combination has its own gemfile in `gemfiles/` (CI sets `BUNDLE_GEMFILE` to it): +每個 Ruby / Rails 組合在 `gemfiles/` 內都有各自的 gemfile(CI 會把 `BUNDLE_GEMFILE` 指到它): ```bash BUNDLE_GEMFILE=$PWD/gemfiles/ruby_3.4_rails_8.1.gemfile bundle install BUNDLE_GEMFILE=$PWD/gemfiles/ruby_3.4_rails_8.1.gemfile bundle exec rake test ``` -`bin/test_all` runs every supported Ruby (needs rbenv or rvm) and `bin/check_dependencies` checks dependency compatibility; see [TESTING.md](TESTING.md) for details (its version lists can lag behind CI). +`bin/test_all` 會跑過所有支援的 Ruby 版本(需要 rbenv 或 rvm),`bin/check_dependencies` 會檢查相依套件的相容性;詳見 [TESTING.md](TESTING.md)。 -## Releasing +## 發版 -Releases are automated with RubyGems [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (no API key is stored in the repo): +發版透過 RubyGems [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) 自動完成(repo 內不存放任何 API key): -1. In your PR, bump `ExportTo::VERSION` in `lib/export_to/version.rb` ([Semantic Versioning](https://semver.org/): patch for fixes, minor for compatible features, major for breaking changes). -2. Add a matching `## [x.y.z] - YYYY-MM-DD` section to `CHANGELOG.md`. -3. A human reviews and merges the PR into `master` (AI accounts can only open PRs). -4. [`.github/workflows/publish.yml`](.github/workflows/publish.yml) runs on `master`: if the gemspec version is not on rubygems.org yet it runs `rake release` (builds the gem, tags `vX.Y.Z`, publishes). A merge without a version bump publishes nothing. +1. 在你的 PR 裡修改 `lib/export_to/version.rb` 的 `ExportTo::VERSION`(遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/):修 bug 跳 patch、相容的新功能跳 minor、不相容的變更跳 major)。 +2. 在 `CHANGELOG.md` 新增對應的 `## [x.y.z] - YYYY-MM-DD` 區塊。 +3. 由真人 review 後合併進 `master`(AI 帳號只能開 PR)。 +4. [`.github/workflows/publish.yml`](.github/workflows/publish.yml) 會在 `master` 上執行:如果 gemspec 的版本還沒上 rubygems.org,就執行 `rake release`(打包 gem、打 `vX.Y.Z` tag、發佈)。沒有跳版號的合併不會發佈任何東西。 -Docs/CI-only PRs do not need a version bump. See [CLAUDE.md](CLAUDE.md): every PR must also update `README.md` (when behavior or usage changes) and add an entry to `CHANGELOG.md`. +只改文件 / CI 的 PR 不需要跳版號。請見 [CLAUDE.md](CLAUDE.md):每個 PR 也都必須更新 `README.md`(行為或用法有變時)並在 `CHANGELOG.md` 新增一筆條目。 -## Contributing +## 貢獻 -Bug reports and pull requests are welcome on GitHub at https://github.com/superlanding/export_to. +歡迎到 GitHub 回報問題與提交 pull request:https://github.com/superlanding/export_to。 -## License +## 授權 -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +本 gem 以 [MIT License](https://opensource.org/licenses/MIT) 條款開源。 diff --git a/TESTING.md b/TESTING.md index ffd5848..9c80b8d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,103 +1,45 @@ # Testing Guide -This gem supports multiple Ruby and Rails versions: -- Ruby 2.7 with Rails 6.1 -- Ruby 3.0 with Rails 6.1, 7.0 -- Ruby 3.1 with Rails 6.1, 7.0, 7.1 +Tests use Minitest + shoulda-context (`test/**/*_test.rb`) and run against several Ruby / Rails combinations. -## Multi-Version Testing +The supported matrix is defined in **one place**: [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Each combination has a gemfile `gemfiles/ruby__rails_.gemfile`. Lists of versions are intentionally not copied here so they cannot go stale; see the [README](README.md#requirements) for the current table. -### Local Testing +## Run the tests for one combination -#### Test all Ruby versions at once - -Run the test suite against all supported Ruby versions: +CI selects the gemfile with `BUNDLE_GEMFILE`; do the same locally (switch to the matching Ruby first, e.g. with rbenv): ```bash -bin/test_all +export BUNDLE_GEMFILE=$PWD/gemfiles/ruby_3.4_rails_8.1.gemfile +bundle install +bundle exec rake test # also: rake t / rake ``` -This script will: -- Automatically switch between Ruby versions (requires rbenv or rvm) -- Install dependencies for each version -- Run the test suite for each version -- Display a summary of results - -#### Test a specific Ruby/Rails version combination +## Run the whole CI matrix locally ```bash -# Ruby 2.7 with Rails 6.1 -export BUNDLE_GEMFILE=gemfiles/ruby_2.7_rails_6.1.gemfile -bundle install -bundle exec rake test - -# Ruby 3.0 with Rails 6.1 -export BUNDLE_GEMFILE=gemfiles/ruby_3.0_rails_6.1.gemfile -bundle install -bundle exec rake test - -# Ruby 3.0 with Rails 7.0 -export BUNDLE_GEMFILE=gemfiles/ruby_3.0_rails_7.0.gemfile -bundle install -bundle exec rake test - -# Ruby 3.1 with Rails 7.1 -export BUNDLE_GEMFILE=gemfiles/ruby_3.1_rails_7.1.gemfile -bundle install -bundle exec rake test +bin/test_all ``` -### Dependency Checking +`bin/test_all` needs rbenv or rvm. For every entry of its `TEST_MATRIX` (kept equal to the CI matrix) it switches to the newest installed patch version of that Ruby, runs `bundle install` with the matching gemfile, runs `rake test` and prints a summary. A Ruby that is not installed is reported as a failure. When you change `ci.yml`, update `TEST_MATRIX` in `bin/test_all` as well. -Check if all dependencies are compatible with all Ruby versions: +## Check dependencies ```bash bin/check_dependencies ``` -This script will verify that all dependencies can be installed and will report any outdated gems. - -## CI/CD - -The gem uses GitHub Actions for continuous integration. Every push and pull request will automatically test against all supported combinations: -- Ruby 2.7 / Rails 6.1 -- Ruby 3.0 / Rails 6.1, 7.0 -- Ruby 3.1 / Rails 6.1, 7.0, 7.1 - -See `.github/workflows/ci.yml` for the full configuration. - -## Gemfiles Structure - -The `gemfiles/` directory contains version-specific Gemfiles for each Ruby/Rails combination: -- `ruby_2.7_rails_6.1.gemfile` - Ruby 2.7 with Rails 6.1 -- `ruby_3.0_rails_6.1.gemfile` - Ruby 3.0 with Rails 6.1 -- `ruby_3.0_rails_7.0.gemfile` - Ruby 3.0 with Rails 7.0 -- `ruby_3.1_rails_6.1.gemfile` - Ruby 3.1 with Rails 6.1 -- `ruby_3.1_rails_7.0.gemfile` - Ruby 3.1 with Rails 7.0 -- `ruby_3.1_rails_7.1.gemfile` - Ruby 3.1 with Rails 7.1 +Verifies that the dependencies can be installed for the supported versions and reports outdated gems. -Each gemfile has its own lockfile committed to the repository to ensure consistent dependency versions across environments. +## Adding a Ruby / Rails combination -## Requirements +1. Add `gemfiles/ruby__rails_.gemfile` (copy a neighbouring one and change the `activesupport` / `railties` versions). +2. Add the combination to the matrix in `.github/workflows/ci.yml` and to `TEST_MATRIX` in `bin/test_all`. +3. Update the table in the README. -### For Local Multi-Version Testing +## Lockfiles -You need either: -- **rbenv** with Ruby 2.7, 3.0, and 3.1 installed -- **rvm** with Ruby 2.7, 3.0, and 3.1 installed +Some gemfiles have a committed `*.gemfile.lock`, and `Gemfile.lock` is committed too. They contain the gem's own version (`export_to (x.y.z)`), so **keep them consistent: bump the version in those files whenever `lib/export_to/version.rb` changes**. -### Installing Ruby Versions +## CI -#### Using rbenv: -```bash -rbenv install 2.7.8 -rbenv install 3.0.6 -rbenv install 3.1.4 -``` - -#### Using rvm: -```bash -rvm install 2.7.8 -rvm install 3.0.6 -rvm install 3.1.4 -``` +GitHub Actions runs every matrix entry on every push to `master` and on every pull request that targets `master`. diff --git a/bin/test_all b/bin/test_all index d2b9b5e..d61ca2e 100755 --- a/bin/test_all +++ b/bin/test_all @@ -6,13 +6,22 @@ set -e # Define test matrix: ruby_version:rails_version +# Keep in sync with the matrix in .github/workflows/ci.yml (and gemfiles/). +# ruby_version is "major.minor"; the newest installed patch version is used. TEST_MATRIX=( - "2.7.6:6.1" - "3.0.7:6.1" - "3.0.7:7.0" - "3.1.7:6.1" - "3.1.7:7.0" - "3.1.7:7.1" + "2.7:6.1" + "3.0:6.1" + "3.0:7.0" + "3.1:6.1" + "3.1:7.0" + "3.1:7.1" + "3.2:7.0" + "3.2:7.1" + "3.3:7.1" + "3.3:8.0" + "3.3:8.1" + "3.4:8.0" + "3.4:8.1" ) SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" @@ -60,13 +69,14 @@ for test_case in "${TEST_MATRIX[@]}"; do # Set Ruby version if [ "$RUBY_MANAGER" == "rbenv" ]; then - if ! rbenv versions | grep -q "^[* ]*${ruby_version}"; then + installed_version="$(rbenv versions --bare | grep -E "^${ruby_version//./\\.}\\." | sort -V | tail -n 1)" + if [ -z "${installed_version}" ]; then echo -e "${RED}Ruby ${ruby_version} not installed in rbenv. Skipping...${NC}" FAILED=$((FAILED + 1)) FAILED_TESTS+=("Ruby ${ruby_version} / Rails ${rails_version} (Ruby not installed)") continue fi - export RBENV_VERSION="${ruby_version}" + export RBENV_VERSION="${installed_version}" elif [ "$RUBY_MANAGER" == "rvm" ]; then if ! rvm list | grep -q "ruby-${ruby_version}"; then echo -e "${RED}Ruby ${ruby_version} not installed in rvm. Skipping...${NC}" diff --git a/gemfiles/ruby_3.1_rails_7.0.gemfile.lock b/gemfiles/ruby_3.1_rails_7.0.gemfile.lock index 78f7bd4..e52abd7 100644 --- a/gemfiles/ruby_3.1_rails_7.0.gemfile.lock +++ b/gemfiles/ruby_3.1_rails_7.0.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - export_to (1.2.1) + export_to (1.3.0) activesupport (>= 6.0, < 8.2) csv fast_excel (~> 0.4) diff --git a/gemfiles/ruby_3.3_rails_8.0.gemfile.lock b/gemfiles/ruby_3.3_rails_8.0.gemfile.lock index aa8c0a7..b007fb5 100644 --- a/gemfiles/ruby_3.3_rails_8.0.gemfile.lock +++ b/gemfiles/ruby_3.3_rails_8.0.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - export_to (1.2.1) + export_to (1.3.0) activesupport (>= 6.0, < 8.2) csv fast_excel (~> 0.4) diff --git a/gemfiles/ruby_3.3_rails_8.1.gemfile.lock b/gemfiles/ruby_3.3_rails_8.1.gemfile.lock index 4dcfcd9..eab4819 100644 --- a/gemfiles/ruby_3.3_rails_8.1.gemfile.lock +++ b/gemfiles/ruby_3.3_rails_8.1.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - export_to (1.2.1) + export_to (1.3.0) activesupport (>= 6.0, < 8.2) csv fast_excel (~> 0.4) diff --git a/gemfiles/ruby_3.4_rails_8.0.gemfile.lock b/gemfiles/ruby_3.4_rails_8.0.gemfile.lock index 9856941..420ff9e 100644 --- a/gemfiles/ruby_3.4_rails_8.0.gemfile.lock +++ b/gemfiles/ruby_3.4_rails_8.0.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - export_to (1.2.1) + export_to (1.3.0) activesupport (>= 6.0, < 8.2) csv fast_excel (~> 0.4) @@ -183,7 +183,7 @@ CHECKSUMS drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - export_to (1.2.1) + export_to (1.3.0) fast_excel (0.5.0) sha256=59c418bdcf586a6030798d4e3a9f575742badaceaf7bb90f38eee5feb5bdc478 ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 diff --git a/gemfiles/ruby_3.4_rails_8.1.gemfile.lock b/gemfiles/ruby_3.4_rails_8.1.gemfile.lock index e72dec1..9c074f1 100644 --- a/gemfiles/ruby_3.4_rails_8.1.gemfile.lock +++ b/gemfiles/ruby_3.4_rails_8.1.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - export_to (1.2.1) + export_to (1.3.0) activesupport (>= 6.0, < 8.2) csv fast_excel (~> 0.4) @@ -182,7 +182,7 @@ CHECKSUMS drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - export_to (1.2.1) + export_to (1.3.0) fast_excel (0.5.0) sha256=59c418bdcf586a6030798d4e3a9f575742badaceaf7bb90f38eee5feb5bdc478 ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 diff --git a/lib/export_to/base.rb b/lib/export_to/base.rb index 510206d..f16ce84 100644 --- a/lib/export_to/base.rb +++ b/lib/export_to/base.rb @@ -25,17 +25,25 @@ def to_xlsx ExportTo::Exporter::Xlsx.new(self).export end - # 舊版 Excel + # 注意:名稱叫 xls,但自 1.0.0 起實際回傳的是 XLSX 內容(與 #to_xlsx 相同), + # 為了不破壞既有呼叫端,行為維持不變,僅在第一次呼叫時印出 deprecation 警告。 + # 請改用 #to_xlsx;需要真正的舊版 .xls 請用 ExportTo::Exporter::Xls.new(export).to_xls def to_xls - ExportTo::Exporter::Xlsx.new(self).export + self.class.warn_to_xls_deprecated(caller_locations(1, 1).first) + to_xlsx end + # 回傳所有列(第一列是表頭)的二維陣列,與 CSV / XLSX 看到的內容一致。 + # 給 block 時逐列 yield columns 並回傳 self(Enumerable 的方法因此可用)。 def each data = [] - rows! do |columns, model, x| + each! do |columns, _model, _index| data.push(columns) end - data + return data unless block_given? + + data.each { |columns| yield(columns) } + self end def each! @@ -92,6 +100,16 @@ def find_in_batches end class << self + # to_xls 只警告一次(每個 process),避免洗版 log + def warn_to_xls_deprecated(location = nil) + return if Base.to_xls_warned + Base.to_xls_warned = true + where = location ? " (called from #{location})" : "" + Kernel.warn "[DEPRECATION] ExportTo::Base#to_xls returns XLSX data, not a legacy .xls file; " \ + "use #to_xlsx instead. #to_xls keeps this behavior for compatibility#{where}." + end + + attr_accessor :to_xls_warned protected diff --git a/lib/export_to/version.rb b/lib/export_to/version.rb index f2e25e6..a792839 100644 --- a/lib/export_to/version.rb +++ b/lib/export_to/version.rb @@ -1,3 +1,3 @@ module ExportTo - VERSION = "1.2.1" + VERSION = "1.3.0" end diff --git a/test/export_user_test.rb b/test/export_user_test.rb index 89c35b8..91586fa 100644 --- a/test/export_user_test.rb +++ b/test/export_user_test.rb @@ -43,15 +43,89 @@ def amount @export = ExportUser.new(@users) end + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + + def reset_to_xls_warning(value) + ExportTo::Base.to_xls_warned = value if ExportTo::Base.respond_to?(:to_xls_warned=) + end + + XLSX_MAGIC = "PK\x03\x04".b # an .xlsx file is a zip archive + XLS_MAGIC = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b # a legacy .xls file is an OLE2 compound file + describe "#to_xlsx" do should "will not throw exception" do assert @export.to_xlsx.present? end + + should "return xlsx (zip) data" do + assert_equal XLSX_MAGIC, @export.to_xlsx.b[0, 4] + end end describe "#to_xls" do + before { reset_to_xls_warning(nil) } + after { reset_to_xls_warning(true) } + should "will not throw exception" do - assert @export.to_xls.present? + capture_stderr { assert @export.to_xls.present? } + end + + # Compatibility: since 1.0.0 `to_xls` returns the same xlsx data as `to_xlsx`. + # Callers may depend on it, so this is intentionally kept (and deprecated). + should "still return xlsx (zip) data, not a legacy xls file" do + capture_stderr do + assert_equal XLSX_MAGIC, @export.to_xls.b[0, 4] + end + end + + should "warn about the deprecation only once" do + first = capture_stderr { @export.to_xls } + second = capture_stderr { @export.to_xls } + assert_match(/DEPRECATION.*to_xls.*to_xlsx/, first) + assert_equal "", second + end + + should "not warn when using #to_xlsx" do + assert_equal "", capture_stderr { @export.to_xlsx } + end + end + + describe "ExportTo::Exporter::Xls" do + should "build a real legacy xls file" do + data = ExportTo::Exporter::Xls.new(@export).to_xls + assert_equal XLS_MAGIC, data.b[0, 8] + end + end + + describe "#each" do + should "return all rows (table head first) as an array" do + assert_equal [ + ['名字', '手機', '地址', '金額'], + ['Eddie Li', '+886977777777', 'Kaohsiung city', 100], + ['Vegeta Lu', '+886938234134', 'Taipei city', 1100], + ['Vegeta Lu', '+886938234134', 'Taipei city', 2100] + ], @export.each + end + + should "match the rows of #to_csv" do + assert_equal CSV.parse(@export.to_csv), @export.each.map { |row| row.map(&:to_s) } + end + + should "yield each row to a block and return self" do + rows = [] + assert_same @export, @export.each { |row| rows << row } + assert_equal @export.each, rows + end + + should "work with Enumerable methods" do + assert_equal ['名字', 'Eddie Li', 'Vegeta Lu', 'Vegeta Lu'], @export.map(&:first) end end