From 399571289fd191798aa03c04a92f4865760dafd5 Mon Sep 17 00:00:00 2001 From: Naohiro CHIKAMATSU Date: Thu, 13 Aug 2026 17:52:08 +0900 Subject: [PATCH 1/5] chore(deps): bump moov-io/ach, moov-io/wire, modernc.org/sqlite, klauspost/compress, and pierrec/lz4 --- go.mod | 26 +++++++++---------- go.sum | 80 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/go.mod b/go.mod index 7d25c2a..f7f471c 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,22 @@ module github.com/nao1215/filesql -go 1.25.0 +go 1.25.8 require ( github.com/apache/arrow/go/v18 v18.0.0-20241007013041-ab95a4d25142 github.com/google/go-cmp v0.7.0 - github.com/klauspost/compress v1.19.1 - github.com/moov-io/ach v1.61.3 - github.com/moov-io/wire v0.15.8 + github.com/klauspost/compress v1.19.2 + github.com/moov-io/ach v1.62.1 + github.com/moov-io/wire v0.15.9 github.com/nao1215/fileparser v0.5.2 github.com/parquet-go/parquet-go v0.30.1 - github.com/pierrec/lz4/v4 v4.1.27 + github.com/pierrec/lz4/v4 v4.1.28 github.com/stretchr/testify v1.11.1 github.com/tiendc/go-deepcopy v1.7.2 github.com/ulikunitz/xz v0.5.16 github.com/xuri/excelize/v2 v2.11.0 golang.org/x/text v0.40.0 - modernc.org/sqlite v1.55.0 + modernc.org/sqlite v1.56.0 ) require ( @@ -32,12 +32,12 @@ require ( github.com/igrmk/treemap/v2 v2.0.1 // indirect github.com/klauspost/asmfmt v1.3.2 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect - github.com/moov-io/base v0.61.3 // indirect + github.com/moov-io/base v0.62.1 // indirect github.com/moov-io/iso3166 v0.4.0 // indirect - github.com/moov-io/iso4217 v0.3.2 // indirect + github.com/moov-io/iso4217 v0.4.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/parquet-go/bitpack v1.0.0 // indirect github.com/parquet-go/jsonlite v1.0.0 // indirect @@ -45,7 +45,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect - github.com/rickar/cal/v2 v2.1.27 // indirect + github.com/rickar/cal/v2 v2.1.28 // indirect github.com/twpayne/go-geom v1.6.1 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect @@ -59,11 +59,11 @@ require ( golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect golang.org/x/tools v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/grpc v1.82.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.74.1 // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 54356fd..995f455 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= @@ -32,8 +32,8 @@ github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81A github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -44,26 +44,26 @@ github.com/igrmk/treemap/v2 v2.0.1 h1:Jhy4z3yhATvYZMWCmxsnHO5NnNZBdueSzvxh6353l+ github.com/igrmk/treemap/v2 v2.0.1/go.mod h1:PkTPvx+8OHS8/41jnnyVY+oVsfkaOUZGcr+sfonosd4= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= -github.com/moov-io/ach v1.61.3 h1:nao68CYEHvzPlAqtNJE1Y2N8mFtiSrJ4+dBBUCEexms= -github.com/moov-io/ach v1.61.3/go.mod h1:Rrloef6U+t7xTPHLrxlwHRlOcO8tTmlAzbot6kL26nk= -github.com/moov-io/base v0.61.3 h1:c0Hvy497geIXbCA915YC6P7XxC4iKu7qdSBU2mcOGKA= -github.com/moov-io/base v0.61.3/go.mod h1:t9UWAnoRBDl6cqcZc3g2Cpm1BJzQ0Q+7xqLHyPVk1Gw= +github.com/moov-io/ach v1.62.1 h1:tosnQJKKK36D6TLEiy5Cb9KDoPOFGmea/FXtXT+7dyo= +github.com/moov-io/ach v1.62.1/go.mod h1:lgC1hB/vpeVESfLh1onXFhZrQNMdlwgLVabblpnWtAs= +github.com/moov-io/base v0.62.1 h1:nM+RdTWMsWaUd572D/Hitgf+wND6vnfnYjM9MNWLd7g= +github.com/moov-io/base v0.62.1/go.mod h1:Rndo5mNk38K74u6zTA8K5pxpsgmnK7CirAf++zXuWuM= github.com/moov-io/iso3166 v0.4.0 h1:WtXIptANC16DrHpbSAt4+itFciCCnA+C6eAi9k7HEsA= github.com/moov-io/iso3166 v0.4.0/go.mod h1:13ubAoOZNfWzs2fN3x467zg8q982U867Ee+ulqrArlM= -github.com/moov-io/iso4217 v0.3.2 h1:/PNKwvt0LCaDx3r5cQEjIdX6a7PEELWJ8D5thi2jiHU= -github.com/moov-io/iso4217 v0.3.2/go.mod h1:IoD1XWQwCZBhFk9YlfQwvRW3TGlk7IoZX9OEe2PG19M= -github.com/moov-io/wire v0.15.8 h1:FqR5UNDv0oGA5pdntRlJ5dyYDvOtJXRTOZmCDxrUqoE= -github.com/moov-io/wire v0.15.8/go.mod h1:UY3HiIprbxpeV9dB/Vj6Zp1rLNDjSff+66lBIxEYpt8= +github.com/moov-io/iso4217 v0.4.0 h1:+97e9chdg8Cx3kMLmGmvN64+CtmtodPLfUT2PaCXUUE= +github.com/moov-io/iso4217 v0.4.0/go.mod h1:QvQE6pvu9KItHusChPLOJS10EhJnyQpcKHloIqHkQVM= +github.com/moov-io/wire v0.15.9 h1:1dUaRHrMK8MTJUAxVg8LHOtQHcNu+atHwMU05NgPHwg= +github.com/moov-io/wire v0.15.9/go.mod h1:VtrraXrIeCXiVo+AGA3aHu+/ewOGMiS/bei2wKN+/LI= github.com/nao1215/fileparser v0.5.2 h1:IXhH5m3UJf/TMidfTcVen8CSlZ4KEFEgBz6+2o+5KMs= github.com/nao1215/fileparser v0.5.2/go.mod h1:Jb16QbtYfgzQ2BR+UOVNSa1WeKMP2o+xnwBYFUNAG2Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -74,8 +74,8 @@ github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZd github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0= github.com/parquet-go/parquet-go v0.30.1 h1:Oy6ganNrAdFiVwy7wNmWagfPTWA2X9Z3tVHBc7JtuX8= github.com/parquet-go/parquet-go v0.30.1/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= -github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= -github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -84,8 +84,8 @@ github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/ github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/rickar/cal/v2 v2.1.27 h1:4vFfbXI9dB1Rb/mHH51xYx36ILWk0Wu8VY0bMnoTMpw= -github.com/rickar/cal/v2 v2.1.27/go.mod h1:/fdlMcx7GjPlIBibMzOM9gMvDBsrK+mOtRXdTzUqV/A= +github.com/rickar/cal/v2 v2.1.28 h1:PLNjsw5YrCMkcE+EtD/wFh0Ys+a4Qp9yN6SKyksVso0= +github.com/rickar/cal/v2 v2.1.28/go.mod h1:/fdlMcx7GjPlIBibMzOM9gMvDBsrK+mOtRXdTzUqV/A= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -110,16 +110,16 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= @@ -145,18 +145,18 @@ golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSm golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= @@ -167,8 +167,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -177,8 +177,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= -modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 11f11b75040e3a4f6a29e39976c8e88e3924e269 Mon Sep 17 00:00:00 2001 From: Naohiro CHIKAMATSU Date: Thu, 13 Aug 2026 17:52:08 +0900 Subject: [PATCH 2/5] fix: keep the line terminator of a file a save overwrites --- CHANGELOG.md | 10 +++ README.md | 9 ++- autosave_line_ending_test.go | 131 +++++++++++++++++++++++++++++++++++ example_api_test.go | 10 +++ filesql.go | 26 +++---- line_ending.go | 91 ++++++++++++++++++++++++ line_ending_test.go | 102 +++++++++++++++++++++++++++ parser/parser_test.go | 43 ++++++++++++ parser/tsv.go | 16 ++++- save.go | 32 ++++++++- save_encoding.go | 6 +- save_encoding_unit_test.go | 2 +- 12 files changed, 459 insertions(+), 19 deletions(-) create mode 100644 autosave_line_ending_test.go create mode 100644 line_ending.go create mode 100644 line_ending_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 669b898..340d2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `DumpOptions.WithLineEnding` sets the line terminator of csv, tsv, and ltsv output, and a save that overwrites a file it loaded from a path takes that file's own terminator without being asked ([#269](https://github.com/nao1215/filesql/issues/269)). A save kept a source's compression and its text encoding but wrote every record with `\n`, so a CRLF file saved in place came back LF throughout: a caller who edited one row got a file whose every line had changed, which is a whole-file diff in a repository configured for CRLF and a file the tools reading it no longer saw as they had. The terminator is read from the file about to be replaced — through its codec, so a `.csv.gz` is read as the text inside it — and a file with mixed terminators keeps whichever the majority of its lines use, so one stray ending cannot rewrite the rest. A dump to a new destination writes `\n` unless `WithLineEnding(LineEndingCRLF)` says otherwise, which is what every save wrote before this existed. `parser.WriteTSVRecordLineEnding` is the same choice for a caller writing TSV records directly. Parquet and XLSX are not line-based and are unaffected. + +### Changed + +- Dependencies: `github.com/moov-io/ach` 1.61.3 → 1.62.1, `github.com/moov-io/wire` 0.15.8 → 0.15.9, `modernc.org/sqlite` 1.55.0 → 1.56.0, `github.com/klauspost/compress` 1.19.1 → 1.19.2, `github.com/pierrec/lz4/v4` 4.1.27 → 4.1.28. + ## [0.43.1] - 2026-08-09 ### Fixed diff --git a/README.md b/README.md index cb34b87..34032c2 100644 --- a/README.md +++ b/README.md @@ -403,18 +403,21 @@ Changes live in memory until you save them. - `EnableAutoSave` saves when `db.Close()` runs. - `EnableAutoSaveOnCommit` saves after each committed transaction. -`DumpOptions` decides the format, the compression, and the text encoding of csv, tsv, and ltsv output: +`DumpOptions` decides the format, the compression, the text encoding, and the line terminator of csv, tsv, and ltsv output: ```go options := filesql.NewDumpOptions(). WithFormat(filesql.OutputFormatCSV). - WithEncoding(filesql.EncodingShiftJIS) + WithEncoding(filesql.EncodingShiftJIS). + WithLineEnding(filesql.LineEndingCRLF) err := filesql.DumpDatabase(db, "./output", options) ``` Output is UTF-8 unless `WithEncoding` says otherwise, which is what a save wrote before the option existed. `EncodingShiftJIS`, `EncodingEUCJP`, `EncodingISO2022JP`, `EncodingUTF16LE`, and `EncodingUTF16BE` are the others; the UTF-16 pair write a byte-order mark, so the read side recognizes them without being told. A value the encoding has no way to write fails the save with `ErrEncoding` and leaves the destination as it was, rather than being replaced with a substitute character — the same answer the read side gives to bytes it cannot decode. Parquet and XLSX carry their own encoding and ignore the option. +Records end with `\n` unless `WithLineEnding` says otherwise. A save that overwrites a file it loaded from a path does not need to be told: it reads the terminator the file already uses and writes the same one, so a CRLF file edited in place stays CRLF and the rows nobody touched stay byte-identical. A file with mixed terminators keeps whichever one the majority of its lines use. Parquet and XLSX are not line-based and ignore the option. + ### Excel sheet visibility A workbook can hide a sheet, and a hidden sheet often holds the spreadsheet's own working-out rather than data anyone meant to publish. filesql loads every sheet by default, hidden or not, so existing programs keep the tables they have. @@ -472,7 +475,7 @@ The GoDoc examples are fully tested with `go test`. The tables below show the fa | Attach your own logger | `ExampleDBBuilder_WithLogger`, `ExampleNewSlogAdapter` | [example_api_test.go](./example_api_test.go) | | Open a read-only wrapper | `ExampleDBBuilder_OpenReadOnly` | [example_api_test.go](./example_api_test.go) | | Save on close or commit | `ExampleDBBuilder_EnableAutoSave`, `ExampleDBBuilder_EnableAutoSaveOnCommit`, `ExampleDBBuilder_DisableAutoSave` | [example_api_test.go](./example_api_test.go), [example_test.go](./example_test.go) | -| Export tables with format/compression/encoding options | `ExampleDumpDatabase`, `ExampleNewDumpOptions`, `ExampleDumpOptions_WithFormat`, `ExampleDumpOptions_WithCompression`, `ExampleDumpOptions_WithEncoding` | [example_api_test.go](./example_api_test.go), [example_test.go](./example_test.go) | +| Export tables with format/compression/encoding/line-ending options | `ExampleDumpDatabase`, `ExampleNewDumpOptions`, `ExampleDumpOptions_WithFormat`, `ExampleDumpOptions_WithCompression`, `ExampleDumpOptions_WithEncoding`, `ExampleDumpOptions_WithLineEnding` | [example_api_test.go](./example_api_test.go), [example_test.go](./example_test.go) | | Work with compression helpers directly | `ExampleNewCompressionHandler`, `ExampleNewCompressionFactory`, `ExampleCompressionFactory_DetectCompressionType` | [example_api_test.go](./example_api_test.go) | | Strip compression suffixes and inspect file types | `ExampleCompressionFactory_RemoveCompressionExtension`, `ExampleCompressionFactory_GetBaseFileType` | [example_api_test.go](./example_api_test.go) | | Inspect the malformed-row policy | `ExampleMalformedRowPolicy_String` | [example_api_test.go](./example_api_test.go) | diff --git a/autosave_line_ending_test.go b/autosave_line_ending_test.go new file mode 100644 index 0000000..758ae31 --- /dev/null +++ b/autosave_line_ending_test.go @@ -0,0 +1,131 @@ +package filesql + +import ( + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAutoSaveOverwriteKeepsLineEnding pins that a save in place writes back the +// terminator the file already used. +// +// It did not: every record was written with "\n" whatever the source used, so a +// CRLF file came back LF throughout. A caller who edited one row got a file +// whose every line had changed — a whole-file diff in a repository configured +// for CRLF, and a file the tools that read it no longer saw as they had. +func TestAutoSaveOverwriteKeepsLineEnding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + content string + update string + want string + }{ + { + name: "CSV keeps CRLF", + file: "crlf.csv", + content: "id,v\r\n1,a\r\n2,b\r\n", + update: "UPDATE crlf SET v='x' WHERE id=1", + want: "id,v\r\n1,x\r\n2,b\r\n", + }, + { + name: "CSV keeps LF", + file: "lf.csv", + content: "id,v\n1,a\n2,b\n", + update: "UPDATE lf SET v='x' WHERE id=1", + want: "id,v\n1,x\n2,b\n", + }, + { + name: "TSV keeps CRLF", + file: "crlf.tsv", + content: "id\tv\r\n1\ta\r\n2\tb\r\n", + update: "UPDATE crlf SET v='x' WHERE id=1", + want: "id\tv\r\n1\tx\r\n2\tb\r\n", + }, + { + name: "LTSV keeps CRLF", + file: "crlf.ltsv", + content: "id:1\tv:a\r\nid:2\tv:b\r\n", + update: "UPDATE crlf SET v='x' WHERE id=1", + want: "id:1\tv:x\r\nid:2\tv:b\r\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), tt.file) + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o600)) + + require.NoError(t, autoSaveOverwrite(t, []string{path}, tt.update)) + + got, err := os.ReadFile(path) //nolint:gosec // Test path from t.TempDir() + require.NoError(t, err) + assert.Equal(t, tt.want, string(got), "only the edited row may differ from what was there") + }) + } +} + +// TestAutoSaveOverwriteKeepsLineEndingUnderCompression checks that the +// terminator is read from the bytes inside the codec, not from the archive. +func TestAutoSaveOverwriteKeepsLineEndingUnderCompression(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "crlf.csv.gz") + file, err := os.Create(path) //nolint:gosec // Test path from t.TempDir() + require.NoError(t, err) + gz := gzip.NewWriter(file) + _, err = gz.Write([]byte("id,v\r\n1,a\r\n2,b\r\n")) + require.NoError(t, err) + require.NoError(t, gz.Close()) + require.NoError(t, file.Close()) + + require.NoError(t, autoSaveOverwrite(t, []string{path}, "UPDATE crlf SET v='x' WHERE id=1")) + + reopened, err := os.Open(path) //nolint:gosec // Test path from t.TempDir() + require.NoError(t, err) + defer reopened.Close() + reader, err := gzip.NewReader(reopened) + require.NoError(t, err) + defer reader.Close() + + decompressed, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, "id,v\r\n1,x\r\n2,b\r\n", string(decompressed)) +} + +// TestDumpDatabase_WithLineEnding covers the option on a dump to a new +// destination, where there is no existing file to take the terminator from. +func TestDumpDatabase_WithLineEnding(t *testing.T) { + t.Parallel() + + source := filepath.Join(t.TempDir(), "users.csv") + require.NoError(t, os.WriteFile(source, []byte("id,v\n1,a\n"), 0o600)) + + db, err := Open(source) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + outputDir := t.TempDir() + require.NoError(t, DumpDatabase(db, outputDir, NewDumpOptions().WithLineEnding(LineEndingCRLF))) + + got, err := os.ReadFile(filepath.Join(outputDir, "users.csv")) //nolint:gosec // Test path from t.TempDir() + require.NoError(t, err) + assert.Equal(t, "id,v\r\n1,a\r\n", string(got)) +} + +// TestNewDumpOptions_DefaultsToLF pins the default, which is what a save wrote +// before the option existed. +func TestNewDumpOptions_DefaultsToLF(t *testing.T) { + t.Parallel() + + assert.Equal(t, LineEndingLF, NewDumpOptions().LineEnding) + assert.Equal(t, LineEndingCRLF, NewDumpOptions().WithLineEnding(LineEndingCRLF).LineEnding) +} diff --git a/example_api_test.go b/example_api_test.go index a89e5a4..44601b5 100644 --- a/example_api_test.go +++ b/example_api_test.go @@ -362,6 +362,16 @@ func ExampleDumpOptions_WithEncoding() { // shift-jis } +func ExampleDumpOptions_WithLineEnding() { + // Output ends its records with "\n" unless asked otherwise. A save that + // overwrites a file it loaded from a path keeps that file's own terminator + // without being told. + opts := filesql.NewDumpOptions().WithLineEnding(filesql.LineEndingCRLF) + fmt.Println(opts.LineEnding) + // Output: + // crlf +} + func ExampleOutputFormat_Extension() { fmt.Println(filesql.OutputFormatParquet.Extension()) // Output: diff --git a/filesql.go b/filesql.go index 1da761c..c0fa8dc 100644 --- a/filesql.go +++ b/filesql.go @@ -531,11 +531,11 @@ func writeSQLiteTableDataTo(w io.Writer, tableName string, columns []string, row var writeErr error switch options.Format { case OutputFormatCSV: - writeErr = writeCSVData(encoded, columns, rows) + writeErr = writeCSVData(encoded, columns, rows, options.LineEnding) case OutputFormatTSV: - writeErr = writeTSVData(encoded, columns, rows) + writeErr = writeTSVData(encoded, columns, rows, options.LineEnding) case OutputFormatLTSV: - writeErr = writeLTSVData(encoded, columns, rows) + writeErr = writeLTSVData(encoded, columns, rows, options.LineEnding) default: return fmt.Errorf("%w: unsupported output format: %v", ErrUnsupportedFormat, options.Format) } @@ -562,11 +562,13 @@ func createCompressedWriter(w io.Writer, compression CompressionType) (io.Writer const loneEmptyField = `""` // writeDelimitedData writes data in CSV or TSV format based on delimiter -func writeDelimitedData(writer io.Writer, columns []string, rows *sql.Rows, delimiter rune) error { +func writeDelimitedData(writer io.Writer, columns []string, rows *sql.Rows, delimiter rune, lineEnding LineEnding) error { csvWriter := csv.NewWriter(writer) if delimiter != csvDelimiter { csvWriter.Comma = delimiter } + csvWriter.UseCRLF = lineEnding == LineEndingCRLF + terminator := lineEnding.terminator() // writeRecord writes one record, taking the lone empty field around the csv // writer. Flushing first keeps the two writers' output in order. @@ -574,7 +576,7 @@ func writeDelimitedData(writer io.Writer, columns []string, rows *sql.Rows, deli // TSV is written literally; see parser.WriteTSVRecord. A blank line is // already the one-column empty value there, so it needs no form of its own. if delimiter == tsvDelimiter { - return parser.WriteTSVRecord(writer, record) + return parser.WriteTSVRecordLineEnding(writer, record, terminator) } if len(record) != 1 || record[0] != "" { return csvWriter.Write(record) @@ -583,7 +585,7 @@ func writeDelimitedData(writer io.Writer, columns []string, rows *sql.Rows, deli if err := csvWriter.Error(); err != nil { return err } - _, err := io.WriteString(writer, loneEmptyField+"\n") + _, err := io.WriteString(writer, loneEmptyField+terminator) return err } @@ -659,17 +661,17 @@ func formatDumpValue(value any) string { } // writeCSVData writes data in CSV format -func writeCSVData(writer io.Writer, columns []string, rows *sql.Rows) error { - return writeDelimitedData(writer, columns, rows, csvDelimiter) +func writeCSVData(writer io.Writer, columns []string, rows *sql.Rows, lineEnding LineEnding) error { + return writeDelimitedData(writer, columns, rows, csvDelimiter, lineEnding) } // writeTSVData writes data in TSV format -func writeTSVData(writer io.Writer, columns []string, rows *sql.Rows) error { - return writeDelimitedData(writer, columns, rows, tsvDelimiter) +func writeTSVData(writer io.Writer, columns []string, rows *sql.Rows, lineEnding LineEnding) error { + return writeDelimitedData(writer, columns, rows, tsvDelimiter, lineEnding) } // writeLTSVData writes data in LTSV format -func writeLTSVData(writer io.Writer, columns []string, rows *sql.Rows) error { +func writeLTSVData(writer io.Writer, columns []string, rows *sql.Rows, lineEnding LineEnding) error { // A label is read up to the first colon, so a colon in a column name would // make the rest of the name part of the value. Checked once, ahead of the // rows, because it does not depend on them. @@ -702,7 +704,7 @@ func writeLTSVData(writer io.Writer, columns []string, rows *sql.Rows) error { parts = append(parts, col+":"+value) } - line := strings.Join(parts, "\t") + "\n" + line := strings.Join(parts, "\t") + lineEnding.terminator() if _, err := writer.Write([]byte(line)); err != nil { return err } diff --git a/line_ending.go b/line_ending.go new file mode 100644 index 0000000..0568f2a --- /dev/null +++ b/line_ending.go @@ -0,0 +1,91 @@ +package filesql + +import ( + "bytes" + "io" +) + +// LineEnding is the line terminator a save writes its records with. +// +// A save already keeps a source's compression and its text encoding, but wrote +// every record with "\n" whatever the file used. A CRLF file saved in place came +// back LF throughout: every line of the file changed while the caller had edited +// one row, so the file no longer matched what a CRLF-expecting reader — or a +// repository configured for CRLF — had before. This is that decision made +// explicit, and an in-place save makes it from the file itself. +type LineEnding int + +const ( + // LineEndingLF writes "\n", which is what a save wrote before this option + // existed and remains the default. + LineEndingLF LineEnding = iota + // LineEndingCRLF writes "\r\n". + LineEndingCRLF +) + +// String returns the name a user types for the line ending. +func (l LineEnding) String() string { + switch l { + case LineEndingLF: + return "lf" + case LineEndingCRLF: + return "crlf" + default: + return unknownName + } +} + +// terminator returns the bytes the line ending is written as. An unknown value +// writes "\n", the same answer a zero DumpOptions gives. +func (l LineEnding) terminator() string { + if l == LineEndingCRLF { + return "\r\n" + } + return "\n" +} + +// lineEndingSampleSize is how much of a file is read to decide its line ending. +// The terminator does not change halfway through a real file, so a sample +// settles it; reading the whole of a large file to count the rest would cost the +// save a second full read. +const lineEndingSampleSize = 1 << 20 // 1 MiB + +// detectLineEnding reports the line terminator path already uses, so a save that +// overwrites it can write the same one. +// +// The rule is the majority of the terminators in the sample, and LF on a tie or +// on a file with no line ending at all. Majority rather than first-line-wins +// because the point is to leave rows the caller did not edit byte-identical: a +// file that is LF except for one stray CRLF stays LF, where following the first +// line would rewrite every other line in it. +// +// A file this package cannot read is reported as LF, which is what a save wrote +// before this existed. The detection is an improvement on the destination's +// behalf, so failing to detect must not fail the save. +func detectLineEnding(path string) LineEnding { + reader, cleanup, err := NewCompressionFactory().CreateReaderForFile(path) + if err != nil { + return LineEndingLF + } + defer func() { + _ = cleanup() //nolint:errcheck // Reading for detection only; a close failure cannot affect the answer + }() + + sample, err := io.ReadAll(io.LimitReader(reader, lineEndingSampleSize)) + if err != nil && len(sample) == 0 { + return LineEndingLF + } + return dominantLineEnding(sample) +} + +// dominantLineEnding is detectLineEnding's rule, over bytes already in hand. +func dominantLineEnding(sample []byte) LineEnding { + crlf := bytes.Count(sample, []byte("\r\n")) + // Every "\r\n" also contains the "\n" counted here, so the lone ones are what + // is left after removing them. + lf := bytes.Count(sample, []byte("\n")) - crlf + if crlf > lf { + return LineEndingCRLF + } + return LineEndingLF +} diff --git a/line_ending_test.go b/line_ending_test.go new file mode 100644 index 0000000..8ad71f0 --- /dev/null +++ b/line_ending_test.go @@ -0,0 +1,102 @@ +package filesql + +import ( + "compress/gzip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLineEnding_String pins the names of the terminators. +func TestLineEnding_String(t *testing.T) { + t.Parallel() + + assert.Equal(t, "lf", LineEndingLF.String()) + assert.Equal(t, "crlf", LineEndingCRLF.String()) + assert.Equal(t, unknownName, LineEnding(9).String()) +} + +// TestLineEnding_Terminator covers the bytes each value writes, including a +// value from outside the set: a save must still terminate its records, so an +// unknown one writes the default rather than nothing. +func TestLineEnding_Terminator(t *testing.T) { + t.Parallel() + + assert.Equal(t, "\n", LineEndingLF.terminator()) + assert.Equal(t, "\r\n", LineEndingCRLF.terminator()) + assert.Equal(t, "\n", LineEnding(9).terminator()) +} + +// TestDominantLineEnding covers the rule that decides a file's terminator. The +// majority wins so that a file with one stray ending keeps the one the rest of +// its lines use — rewriting those lines is the loss this whole feature exists to +// prevent. +func TestDominantLineEnding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sample string + want LineEnding + }{ + {"all CRLF", "id,v\r\n1,a\r\n2,b\r\n", LineEndingCRLF}, + {"all LF", "id,v\n1,a\n2,b\n", LineEndingLF}, + {"mostly CRLF", "id,v\r\n1,a\r\n2,b\n", LineEndingCRLF}, + {"mostly LF", "id,v\n1,a\n2,b\r\n", LineEndingLF}, + {"a tie keeps LF", "id,v\r\n1,a\n", LineEndingLF}, + {"no line ending at all", "id,v", LineEndingLF}, + {"nothing at all", "", LineEndingLF}, + {"a lone carriage return is not a terminator", "id,v\r1,a\n", LineEndingLF}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, dominantLineEnding([]byte(tt.sample))) + }) + } +} + +// TestDetectLineEnding covers reading the terminator off a file, which is what +// an in-place save does before it rewrites one. +func TestDetectLineEnding(t *testing.T) { + t.Parallel() + + t.Run("a plain file", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + crlf := filepath.Join(dir, "crlf.csv") + lf := filepath.Join(dir, "lf.csv") + require.NoError(t, os.WriteFile(crlf, []byte("id,v\r\n1,a\r\n"), 0o600)) + require.NoError(t, os.WriteFile(lf, []byte("id,v\n1,a\n"), 0o600)) + + assert.Equal(t, LineEndingCRLF, detectLineEnding(crlf)) + assert.Equal(t, LineEndingLF, detectLineEnding(lf)) + }) + + t.Run("a compressed file is read through its codec", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "crlf.csv.gz") + file, err := os.Create(path) //nolint:gosec // Test path from t.TempDir() + require.NoError(t, err) + gz := gzip.NewWriter(file) + _, err = gz.Write([]byte("id,v\r\n1,a\r\n")) + require.NoError(t, err) + require.NoError(t, gz.Close()) + require.NoError(t, file.Close()) + + assert.Equal(t, LineEndingCRLF, detectLineEnding(path), + "the terminator is in the compressed bytes, so the codec has to be undone to see it") + }) + + t.Run("a file that cannot be read answers with the default", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, LineEndingLF, detectLineEnding(filepath.Join(t.TempDir(), "missing.csv")), + "detection is on the destination's behalf and must not fail the save") + }) +} diff --git a/parser/parser_test.go b/parser/parser_test.go index 0b572e0..22272d7 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -312,6 +312,49 @@ func TestWriteTSVRecord(t *testing.T) { }) } +func TestWriteTSVRecordLineEnding(t *testing.T) { + t.Parallel() + + t.Run("writes the terminator it is given", func(t *testing.T) { + t.Parallel() + + var out strings.Builder + require.NoError(t, WriteTSVRecordLineEnding(&out, []string{"id", "v"}, "\r\n")) + assert.Equal(t, "id\tv\r\n", out.String()) + }) + + t.Run("an empty terminator still ends the record", func(t *testing.T) { + t.Parallel() + + var out strings.Builder + require.NoError(t, WriteTSVRecordLineEnding(&out, []string{"id", "v"}, "")) + assert.Equal(t, "id\tv\n", out.String(), "records that run together are not TSV at all") + }) + + t.Run("a CRLF record round trips through the reader", func(t *testing.T) { + t.Parallel() + + record := []string{"1", "a"} + + var out strings.Builder + require.NoError(t, WriteTSVRecordLineEnding(&out, record, "\r\n")) + got, err := NewTSVReader(strings.NewReader(out.String())).ReadAll() + + require.NoError(t, err) + assert.Equal(t, [][]string{record}, got) + }) + + t.Run("a value the format cannot hold is refused whatever the terminator", func(t *testing.T) { + t.Parallel() + + var out strings.Builder + err := WriteTSVRecordLineEnding(&out, []string{"a\tb"}, "\r\n") + + require.ErrorIs(t, err, ErrTSVUnrepresentable) + assert.Empty(t, out.String(), "nothing is written for a refused record") + }) +} + func TestParse_LTSV(t *testing.T) { t.Parallel() diff --git a/parser/tsv.go b/parser/tsv.go index 42af4e2..a26d85c 100644 --- a/parser/tsv.go +++ b/parser/tsv.go @@ -88,13 +88,27 @@ func (t *TSVReader) ReadAll() ([][]string, error) { // A CSV writer would quote such a field instead, and to this reader a quote is // data, so what came back would carry the quotes the writer added. func WriteTSVRecord(w io.Writer, record []string) error { + return WriteTSVRecordLineEnding(w, record, "\n") +} + +// WriteTSVRecordLineEnding is WriteTSVRecord with the line terminator named, +// for a writer that has to keep the one its destination already uses: a file +// rewritten with a different terminator differs on every line, including the +// ones nobody edited. +// +// An empty lineEnding writes "\n", so a zero value behaves as WriteTSVRecord +// does rather than running the records together. +func WriteTSVRecordLineEnding(w io.Writer, record []string, lineEnding string) error { for _, field := range record { if i := strings.IndexAny(field, "\t\n\r"); i >= 0 { return fmt.Errorf("%w: field %q contains %q", ErrTSVUnrepresentable, field, field[i:i+1]) } } - if _, err := io.WriteString(w, strings.Join(record, "\t")+"\n"); err != nil { + if lineEnding == "" { + lineEnding = "\n" + } + if _, err := io.WriteString(w, strings.Join(record, "\t")+lineEnding); err != nil { return fmt.Errorf("failed to write TSV record: %w", err) } return nil diff --git a/save.go b/save.go index beb676d..1afb60e 100644 --- a/save.go +++ b/save.go @@ -209,6 +209,9 @@ type DumpOptions struct { // Encoding specifies the text encoding of csv, tsv, and ltsv output. It has // no effect on Parquet and XLSX, which carry their own. Encoding Encoding + // LineEnding specifies the line terminator of csv, tsv, and ltsv output. It + // has no effect on Parquet and XLSX, which are not line-based. + LineEnding LineEnding } // NewDumpOptions creates default export options (CSV, no compression). @@ -221,6 +224,7 @@ func NewDumpOptions() DumpOptions { Format: OutputFormatCSV, Compression: CompressionNone, Encoding: EncodingUTF8, + LineEnding: LineEndingLF, } } @@ -277,6 +281,24 @@ func (o DumpOptions) WithEncoding(enc Encoding) DumpOptions { return o } +// WithLineEnding sets the line terminator of csv, tsv, and ltsv output. +// +// It exists for the same reason WithEncoding does: a save wrote "\n" whatever +// the source used, so a CRLF file saved in place came back LF throughout — every +// line of the file changed although the caller had edited one row. A save that +// overwrites a file it loaded from a path detects the file's own terminator and +// keeps it, so this option is for a dump to a new destination. +// +// Options: +// - LineEndingLF: "\n" (default) +// - LineEndingCRLF: "\r\n" +// +// Parquet and XLSX are not line-based and are unaffected. +func (o DumpOptions) WithLineEnding(lineEnding LineEnding) DumpOptions { + o.LineEnding = lineEnding + return o +} + // FileExtension returns the complete file extension including compression func (o DumpOptions) FileExtension() string { baseExt := o.Format.Extension() @@ -587,7 +609,15 @@ func (c *autoSaveConnection) overwriteOriginalFile(ctx context.Context, db *sql. } factory := NewCompressionFactory() - options := DumpOptions{Format: format, Compression: factory.DetectCompressionType(path)} + // The line terminator is read from the file about to be replaced, for the + // same reason the compression is read from its name: what comes back has to + // be the file the caller had, with their edit in it. Writing "\n" over a CRLF + // file changed every line of it while one row had been edited. + options := DumpOptions{ + Format: format, + Compression: factory.DetectCompressionType(path), + LineEnding: detectLineEnding(path), + } // An Excel workbook holds a table per sheet, so all of them are written back // together into the one file. The tables of a workbook are named after it, diff --git a/save_encoding.go b/save_encoding.go index 8f08757..029c026 100644 --- a/save_encoding.go +++ b/save_encoding.go @@ -35,6 +35,10 @@ const ( EncodingUTF16BE ) +// unknownName is what a String method answers for a value outside the set its +// type defines, which a caller can produce by converting an int. +const unknownName = "unknown" + // String returns the name a user types for the encoding. func (e Encoding) String() string { switch e { @@ -51,7 +55,7 @@ func (e Encoding) String() string { case EncodingUTF16BE: return "utf-16be" default: - return "unknown" + return unknownName } } diff --git a/save_encoding_unit_test.go b/save_encoding_unit_test.go index 5d53399..85dc920 100644 --- a/save_encoding_unit_test.go +++ b/save_encoding_unit_test.go @@ -36,7 +36,7 @@ func TestEncoding_String(t *testing.T) { {EncodingISO2022JP, nameISO2022JP}, {EncodingUTF16LE, nameUTF16LE}, {EncodingUTF16BE, nameUTF16BE}, - {Encoding(99), "unknown"}, + {Encoding(99), unknownName}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { From bacbf9bf8efacf5ec0f3b682591cd1a7a79b7c9d Mon Sep 17 00:00:00 2001 From: Naohiro CHIKAMATSU Date: Thu, 13 Aug 2026 18:00:46 +0900 Subject: [PATCH 3/5] test: cover the dialect value helpers and the rewrite rules' pass-through cases --- dialect/rewrite_edge_test.go | 132 ++++++++++++++ dialect/value_edge_test.go | 325 +++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 dialect/rewrite_edge_test.go create mode 100644 dialect/value_edge_test.go diff --git a/dialect/rewrite_edge_test.go b/dialect/rewrite_edge_test.go new file mode 100644 index 0000000..11ce7f1 --- /dev/null +++ b/dialect/rewrite_edge_test.go @@ -0,0 +1,132 @@ +package dialect + +import ( + "errors" + "strings" + "testing" +) + +// TestTranslate_LeavesUnrecognizedFormsAlone covers the rewrite rules' "not this +// form" answers. A call that only looks like the one a rule handles is passed +// through unchanged: rewriting it on a guess would turn a query the backend +// understands into one it does not, and the caller never wrote the rewritten +// form. +func TestTranslate_LeavesUnrecognizedFormsAlone(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dialect Dialect + sql string + }{ + {name: "EXTRACT without a part", dialect: PostgreSQL, sql: `SELECT EXTRACT(x) FROM t`}, + {name: "EXTRACT without FROM", dialect: PostgreSQL, sql: `SELECT EXTRACT(year x) FROM t`}, + {name: "CAST without AS", dialect: PostgreSQL, sql: `SELECT CAST(x) FROM t`}, + {name: "CAST to something that is not a type name", dialect: PostgreSQL, sql: `SELECT CAST(x AS 3) FROM t`}, + {name: "CAST to a type this package does not know", dialect: PostgreSQL, sql: `SELECT CAST(x AS quux) FROM t`}, + {name: "DATE_ADD without a second argument", dialect: MySQL, sql: `SELECT DATE_ADD(d) FROM t`}, + {name: "DATE_ADD without INTERVAL", dialect: MySQL, sql: `SELECT DATE_ADD(d, 3) FROM t`}, + {name: "SIMILAR without TO", dialect: PostgreSQL, sql: `SELECT x SIMILAR t FROM t`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := Translate(tt.dialect, tt.sql) + if err != nil { + t.Fatalf("Translate(%q) error: %v", tt.sql, err) + } + if got != tt.sql { + t.Fatalf("Translate(%q) = %q, want it unchanged", tt.sql, got) + } + }) + } +} + +// TestTranslate_RefusesIntervalsItCannotRepresent covers the INTERVAL forms that +// are recognized and cannot be carried out. Each is refused by name rather than +// dropped, because an interval silently left out of a query answers with rows +// from the wrong dates. +func TestTranslate_RefusesIntervalsItCannotRepresent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sql string + want string + }{ + { + name: "no unit", + sql: `SELECT DATE_ADD(d, INTERVAL 1) FROM t`, + want: "missing a unit", + }, + { + name: "a unit no dialect defines", + sql: `SELECT DATE_ADD(d, INTERVAL 1 FORTNIGHT) FROM t`, + want: "unsupported INTERVAL unit", + }, + { + name: "no value", + sql: `SELECT DATE_ADD(d, INTERVAL DAY) FROM t`, + want: "missing a value", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := Translate(MySQL, tt.sql) + if !errors.Is(err, ErrUnsupportedSyntax) { + t.Fatalf("Translate(%q) error = %v, want ErrUnsupportedSyntax", tt.sql, err) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Translate(%q) error = %q, want it to mention %q", tt.sql, err, tt.want) + } + }) + } +} + +// TestTranslate_CastKeepsTypeParameters checks that a parameterized type reaches +// the cast helper whole. Dropping the parameters would turn CHAR(3) into CHAR +// and DECIMAL(10,2) into DECIMAL, so a value would be cast to a different type +// than the one the query names. +func TestTranslate_CastKeepsTypeParameters(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sql string + want string + }{ + {name: "a length", sql: `SELECT CAST(x AS CHAR(3)) FROM t`, want: `'CHAR(3)'`}, + {name: "a precision and scale", sql: `SELECT CAST(x AS DECIMAL(10,2)) FROM t`, want: `'DECIMAL(10,2)'`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := Translate(PostgreSQL, tt.sql) + if err != nil { + t.Fatalf("Translate(%q) error: %v", tt.sql, err) + } + if !strings.Contains(got, tt.want) { + t.Fatalf("Translate(%q) = %q, want it to carry %s", tt.sql, got, tt.want) + } + }) + } +} + +// TestTranslate_DateSubNegatesTheAmount pins that subtracting an interval is the +// same helper with the amount negated, so the month clamping is applied in both +// directions rather than only when adding. +func TestTranslate_DateSubNegatesTheAmount(t *testing.T) { + t.Parallel() + + got, err := Translate(MySQL, `SELECT DATE_SUB(d, INTERVAL 1 MONTH) FROM t`) + if err != nil { + t.Fatalf("Translate error: %v", err) + } + if !strings.Contains(got, `interval_add(d, -(1), 'month')`) { + t.Fatalf("Translate = %q, want the amount negated through interval_add", got) + } +} diff --git a/dialect/value_edge_test.go b/dialect/value_edge_test.go new file mode 100644 index 0000000..2a19897 --- /dev/null +++ b/dialect/value_edge_test.go @@ -0,0 +1,325 @@ +package dialect + +import ( + "database/sql/driver" + "math" + "testing" + "time" +) + +// TestCastToBlob covers the BLOB target, which every dialect spells differently +// but which all of them mean as "the value's bytes". +func TestCastToBlob(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value driver.Value + want string + null bool + }{ + {name: "bytes pass through", value: []byte("abc"), want: "abc"}, + {name: "a string becomes its bytes", value: "abc", want: "abc"}, + {name: "a number becomes its digits", value: int64(255), want: "255"}, + {name: "a time becomes its written form", value: time.Date(2026, 7, 28, 13, 5, 9, 0, time.UTC), want: "2026-07-28 13:05:09"}, + {name: "a NULL has no bytes", value: nil, null: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := castToBlob(tt.value) + if err != nil { + t.Fatalf("castToBlob(%v) error: %v", tt.value, err) + } + if tt.null { + if got != nil { + t.Fatalf("castToBlob(%v) = %v, want NULL", tt.value, got) + } + return + } + b, ok := got.([]byte) + if !ok { + t.Fatalf("castToBlob(%v) = %T, want []byte", tt.value, got) + } + if string(b) != tt.want { + t.Fatalf("castToBlob(%v) = %q, want %q", tt.value, b, tt.want) + } + }) + } +} + +// TestCastToBool covers the two answers a non-boolean value gets. MySQL takes +// anything and reads it for truthiness; the other dialects refuse a value that +// is not a boolean, because silently reading "maybe" as false is a wrong answer +// rather than a missing one. +func TestCastToBool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value driver.Value + strict bool + want int64 + wantErr bool + }{ + {name: "a true boolean", value: true, want: 1}, + {name: "a false boolean", value: false, want: 0}, + {name: "a non-zero integer", value: int64(7), want: 1}, + {name: "zero", value: int64(0), want: 0}, + {name: "a non-zero float", value: 0.5, want: 1}, + {name: "a zero float", value: 0.0, want: 0}, + {name: "the word yes", value: " YES ", want: 1}, + {name: "the word off", value: "off", want: 0}, + {name: "a word that is not a boolean is read for truthiness", value: "maybe", want: 1}, + {name: "an empty value is not truthy", value: "", want: 0}, + {name: "a word that is not a boolean, strictly", value: "maybe", strict: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := castToBool(PostgreSQL, tt.value, tt.strict) + if tt.wantErr { + if err == nil { + t.Fatalf("castToBool(%v, strict) = %v, want an error", tt.value, got) + } + return + } + if err != nil { + t.Fatalf("castToBool(%v) error: %v", tt.value, err) + } + if got != tt.want { + t.Fatalf("castToBool(%v) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + +// TestSafeIntArithmetic covers the overflow checks behind SAFE_ADD, +// SAFE_SUBTRACT, and SAFE_MULTIPLY. A result that does not fit answers NULL, +// which is the whole point of the SAFE_ family: wrapping around would produce a +// number of the wrong sign and report success. +func TestSafeIntArithmetic(t *testing.T) { + t.Parallel() + + t.Run("addition", func(t *testing.T) { + t.Parallel() + + if _, ok := safeAddInt(math.MaxInt64, 1); ok { + t.Fatal("adding past the maximum must not report success") + } + if _, ok := safeAddInt(math.MinInt64, -1); ok { + t.Fatal("adding past the minimum must not report success") + } + if got, ok := safeAddInt(2, 3); !ok || got != 5 { + t.Fatalf("safeAddInt(2, 3) = %v, %v", got, ok) + } + }) + + t.Run("subtraction", func(t *testing.T) { + t.Parallel() + + // Subtracting the minimum is negating it, which has no int64 form. + if _, ok := safeSubInt(1, math.MinInt64); ok { + t.Fatal("subtracting the minimum from a positive must not report success") + } + if got, ok := safeSubInt(-1, math.MinInt64); !ok || got != math.MaxInt64 { + t.Fatalf("safeSubInt(-1, MinInt64) = %v, %v", got, ok) + } + if _, ok := safeSubInt(math.MinInt64, 1); ok { + t.Fatal("subtracting past the minimum must not report success") + } + if got, ok := safeSubInt(5, 3); !ok || got != 2 { + t.Fatalf("safeSubInt(5, 3) = %v, %v", got, ok) + } + }) + + t.Run("multiplication", func(t *testing.T) { + t.Parallel() + + if _, ok := safeMulInt(math.MaxInt64, 2); ok { + t.Fatal("multiplying past the maximum must not report success") + } + if got, ok := safeMulInt(6, 7); !ok || got != 42 { + t.Fatalf("safeMulInt(6, 7) = %v, %v", got, ok) + } + }) +} + +// TestToInt covers the conversion every numeric UDF starts from. A value it +// cannot read as a number is not zero: reporting false is what lets the caller +// answer NULL instead of counting it as 0. +func TestToInt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value driver.Value + want int64 + ok bool + }{ + {name: "a NULL is not a number", value: nil}, + {name: "an integer", value: int64(7), want: 7, ok: true}, + {name: "a float truncates", value: 7.9, want: 7, ok: true}, + {name: "true", value: true, want: 1, ok: true}, + {name: "false", value: false, want: 0, ok: true}, + {name: "a numeric string", value: " 42 ", want: 42, ok: true}, + {name: "a string that is not a number", value: "abc"}, + {name: "numeric bytes", value: []byte("42"), want: 42, ok: true}, + {name: "bytes that are not a number", value: []byte("abc")}, + {name: "a value of another type", value: time.Time{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := toInt(tt.value) + if ok != tt.ok { + t.Fatalf("toInt(%v) ok = %v, want %v", tt.value, ok, tt.ok) + } + if ok && got != tt.want { + t.Fatalf("toInt(%v) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + +// TestHexFunctions covers the two HEX spellings. MySQL's answers with the +// hexadecimal of a number's value, where SQLite's own HEX would answer with the +// hexadecimal of the digits' bytes — "323535" for 255 instead of "FF". +func TestHexFunctions(t *testing.T) { + t.Parallel() + + t.Run("MySQL HEX", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value driver.Value + want driver.Value + }{ + {name: "a NULL stays NULL", value: nil, want: nil}, + {name: "an integer", value: int64(255), want: "FF"}, + {name: "a float truncates", value: 255.9, want: "FF"}, + {name: "bytes", value: []byte("abc"), want: "616263"}, + {name: "a numeric string is hexed as its value", value: "255", want: "FF"}, + {name: "a string that is not a number is hexed as its bytes", value: "abc", want: "616263"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := fnMySQLHex([]driver.Value{tt.value}) + if err != nil { + t.Fatalf("fnMySQLHex(%v) error: %v", tt.value, err) + } + if got != tt.want { + t.Fatalf("fnMySQLHex(%v) = %v, want %v", tt.value, got, tt.want) + } + }) + } + }) + + t.Run("GoogleSQL TO_HEX", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value driver.Value + want driver.Value + }{ + {name: "a NULL stays NULL", value: nil, want: nil}, + {name: "bytes", value: []byte("abc"), want: "616263"}, + {name: "a string is taken as its bytes", value: "abc", want: "616263"}, + {name: "a number is taken as the bytes of its digits", value: int64(255), want: "323535"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := fnToHex([]driver.Value{tt.value}) + if err != nil { + t.Fatalf("fnToHex(%v) error: %v", tt.value, err) + } + if got != tt.want { + t.Fatalf("fnToHex(%v) = %v, want %v", tt.value, got, tt.want) + } + }) + } + }) +} + +// TestSimilarToRegexpEscapes covers the characters the SIMILAR TO translation +// has to protect from the regular expression it produces, and the escape a +// pattern can use to make % or _ mean itself. +func TestSimilarToRegexpEscapes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + want string + }{ + {name: "an anchor is escaped", pattern: "^a$", want: `^\^a\$$`}, + {name: "an escape keeps the character after it", pattern: `a\%b`, want: `^a\%b$`}, + {name: "a trailing escape is kept as is", pattern: `a\`, want: `^a\$`}, + {name: "the regex parts pass through", pattern: "(a|b)+[0-9]{2}", want: "^(a|b)+[0-9]{2}$"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := similarToRegexp(tt.pattern); got != tt.want { + t.Fatalf("similarToRegexp(%q) = %q, want %q", tt.pattern, got, tt.want) + } + }) + } +} + +// TestAddInterval covers the interval arithmetic the three dialects share, +// including the month clamping Go's AddDate does not do: "January 31 plus one +// month" is the last day of February, not March 3. +func TestAddInterval(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 1, 31, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + n int64 + unit string + want time.Time + wantErr bool + }{ + {name: "years", n: 1, unit: unitYear, want: time.Date(2027, 1, 31, 10, 0, 0, 0, time.UTC)}, + {name: "quarters", n: 1, unit: unitQuarter, want: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)}, + {name: "months clamp to the last day", n: 1, unit: unitMonth, want: time.Date(2026, 2, 28, 10, 0, 0, 0, time.UTC)}, + {name: "months backwards", n: -1, unit: unitMonth, want: time.Date(2025, 12, 31, 10, 0, 0, 0, time.UTC)}, + {name: "weeks", n: 1, unit: unitWeek, want: time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)}, + {name: "days", n: 1, unit: unitDay, want: time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)}, + {name: "hours", n: 2, unit: unitHour, want: time.Date(2026, 1, 31, 12, 0, 0, 0, time.UTC)}, + {name: "minutes", n: 30, unit: unitMinute, want: time.Date(2026, 1, 31, 10, 30, 0, 0, time.UTC)}, + {name: "seconds", n: 45, unit: unitSecond, want: time.Date(2026, 1, 31, 10, 0, 45, 0, time.UTC)}, + {name: "a unit no dialect defines", n: 1, unit: "fortnight", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := addInterval(base, tt.n, tt.unit) + if tt.wantErr { + if err == nil { + t.Fatalf("addInterval(%d, %q) = %v, want an error", tt.n, tt.unit, got) + } + return + } + if err != nil { + t.Fatalf("addInterval(%d, %q) error: %v", tt.n, tt.unit, err) + } + if !got.Equal(tt.want) { + t.Fatalf("addInterval(%d, %q) = %v, want %v", tt.n, tt.unit, got, tt.want) + } + }) + } +} From dc094e5e270c51056b5d94a7a62c976d21ef9bfb Mon Sep 17 00:00:00 2001 From: Naohiro CHIKAMATSU Date: Thu, 13 Aug 2026 18:16:36 +0900 Subject: [PATCH 4/5] fix: count a file's line endings whole, outside quoted fields, and escape a trailing SIMILAR TO backslash --- CHANGELOG.md | 6 ++- dialect/operators.go | 8 ++- dialect/value_edge_test.go | 2 +- line_ending.go | 81 ++++++++++++++++++++-------- line_ending_test.go | 107 ++++++++++++++++++++++++++++++++++--- memory_fallback_test.go | 8 +-- save.go | 2 +- 7 files changed, 178 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 340d2d5..b14edca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `DumpOptions.WithLineEnding` sets the line terminator of csv, tsv, and ltsv output, and a save that overwrites a file it loaded from a path takes that file's own terminator without being asked ([#269](https://github.com/nao1215/filesql/issues/269)). A save kept a source's compression and its text encoding but wrote every record with `\n`, so a CRLF file saved in place came back LF throughout: a caller who edited one row got a file whose every line had changed, which is a whole-file diff in a repository configured for CRLF and a file the tools reading it no longer saw as they had. The terminator is read from the file about to be replaced — through its codec, so a `.csv.gz` is read as the text inside it — and a file with mixed terminators keeps whichever the majority of its lines use, so one stray ending cannot rewrite the rest. A dump to a new destination writes `\n` unless `WithLineEnding(LineEndingCRLF)` says otherwise, which is what every save wrote before this existed. `parser.WriteTSVRecordLineEnding` is the same choice for a caller writing TSV records directly. Parquet and XLSX are not line-based and are unaffected. +- `DumpOptions.WithLineEnding` sets the line terminator of csv, tsv, and ltsv output, and a save that overwrites a file it loaded from a path takes that file's own terminator without being asked ([#269](https://github.com/nao1215/filesql/issues/269)). A save kept a source's compression and its text encoding but wrote every record with `\n`, so a CRLF file saved in place came back LF throughout: a caller who edited one row got a file whose every line had changed, which is a whole-file diff in a repository configured for CRLF and a file the tools reading it no longer saw as they had. The terminator is read from the file about to be replaced — through its codec, so a `.csv.gz` is read as the text inside it — and a file with mixed terminators keeps whichever the majority of its lines use, so one stray ending cannot rewrite the rest. The whole file is counted, through a fixed buffer rather than in memory, and a line break inside a quoted CSV field is field data rather than a terminator: a workbook-style export with CRLF between records and LF inside a quoted address is a CRLF file. A dump to a new destination writes `\n` unless `WithLineEnding(LineEndingCRLF)` says otherwise, which is what every save wrote before this existed. `parser.WriteTSVRecordLineEnding` is the same choice for a caller writing TSV records directly. Parquet and XLSX are not line-based and are unaffected. + +### Fixed + +- A `SIMILAR TO` pattern that ends in a backslash no longer matches the wrong thing. The translation wrote the trailing backslash through, where it escaped the anchor the translation appends: `a\` became the regular expression `^a\$`, which matches the literal text `a$` and not the backslash the pattern ends with. A trailing escape now escapes itself. ### Changed diff --git a/dialect/operators.go b/dialect/operators.go index 4c3e271..7ea54e1 100644 --- a/dialect/operators.go +++ b/dialect/operators.go @@ -392,11 +392,17 @@ func similarToRegexp(pattern string) string { b.WriteString("\\") b.WriteByte(c) case '\\': - b.WriteByte(c) if i+1 < len(pattern) { + b.WriteByte(c) i++ b.WriteByte(pattern[i]) + break } + // A pattern that ends in an escape has nothing to escape. Written + // through, the backslash would escape the anchor this appends instead, + // so "a\" became ^a\$ — a regex matching a literal "$" rather than the + // backslash the pattern ends with. + b.WriteString(`\\`) default: b.WriteByte(c) } diff --git a/dialect/value_edge_test.go b/dialect/value_edge_test.go index 2a19897..e70aec3 100644 --- a/dialect/value_edge_test.go +++ b/dialect/value_edge_test.go @@ -263,7 +263,7 @@ func TestSimilarToRegexpEscapes(t *testing.T) { }{ {name: "an anchor is escaped", pattern: "^a$", want: `^\^a\$$`}, {name: "an escape keeps the character after it", pattern: `a\%b`, want: `^a\%b$`}, - {name: "a trailing escape is kept as is", pattern: `a\`, want: `^a\$`}, + {name: "a trailing escape escapes itself", pattern: `a\`, want: `^a\\$`}, {name: "the regex parts pass through", pattern: "(a|b)+[0-9]{2}", want: "^(a|b)+[0-9]{2}$"}, } for _, tt := range tests { diff --git a/line_ending.go b/line_ending.go index 0568f2a..ac529d2 100644 --- a/line_ending.go +++ b/line_ending.go @@ -1,7 +1,7 @@ package filesql import ( - "bytes" + "bufio" "io" ) @@ -44,25 +44,28 @@ func (l LineEnding) terminator() string { return "\n" } -// lineEndingSampleSize is how much of a file is read to decide its line ending. -// The terminator does not change halfway through a real file, so a sample -// settles it; reading the whole of a large file to count the rest would cost the -// save a second full read. -const lineEndingSampleSize = 1 << 20 // 1 MiB +// lineEndingReadSize is the buffer the detection reads through. The file is +// counted whole — a terminator that is in the minority over the first megabyte +// can be the majority over the file — but never held whole, so a file larger +// than memory costs the same as a small one. +const lineEndingReadSize = 64 << 10 // 64 KiB // detectLineEnding reports the line terminator path already uses, so a save that -// overwrites it can write the same one. +// overwrites it can write the same one. format is what the file will be written +// back as, which is what says whether a quote in it means anything. // -// The rule is the majority of the terminators in the sample, and LF on a tie or -// on a file with no line ending at all. Majority rather than first-line-wins +// The rule is the majority of the terminators in the file, and LF on a tie or on +// a file with no line ending at all. Majority rather than first-line-wins // because the point is to leave rows the caller did not edit byte-identical: a // file that is LF except for one stray CRLF stays LF, where following the first // line would rewrite every other line in it. // // A file this package cannot read is reported as LF, which is what a save wrote // before this existed. The detection is an improvement on the destination's -// behalf, so failing to detect must not fail the save. -func detectLineEnding(path string) LineEnding { +// behalf, so failing to detect must not fail the save — and a partial count is +// not used either, because half a file is not evidence of what the whole one +// uses. +func detectLineEnding(path string, format OutputFormat) LineEnding { reader, cleanup, err := NewCompressionFactory().CreateReaderForFile(path) if err != nil { return LineEndingLF @@ -71,21 +74,55 @@ func detectLineEnding(path string) LineEnding { _ = cleanup() //nolint:errcheck // Reading for detection only; a close failure cannot affect the answer }() - sample, err := io.ReadAll(io.LimitReader(reader, lineEndingSampleSize)) - if err != nil && len(sample) == 0 { + ending, err := countLineEndings(reader, format) + if err != nil { return LineEndingLF } - return dominantLineEnding(sample) + return ending } -// dominantLineEnding is detectLineEnding's rule, over bytes already in hand. -func dominantLineEnding(sample []byte) LineEnding { - crlf := bytes.Count(sample, []byte("\r\n")) - // Every "\r\n" also contains the "\n" counted here, so the lone ones are what - // is left after removing them. - lf := bytes.Count(sample, []byte("\n")) - crlf +// countLineEndings is detectLineEnding's rule over a stream, reading it whole +// through a fixed buffer. +// +// A quote is honored for CSV alone. A quoted CSV field carries its own line +// breaks, and counting those as terminators is how a workbook-style export — +// CRLF between records, LF inside a quoted address — was read as an LF file and +// rewritten as one. TSV and LTSV define no quoting at all: a quote there is +// data, and tracking it would let one unmatched quote swallow the rest of the +// file's terminators. +func countLineEndings(reader io.Reader, format OutputFormat) (LineEnding, error) { + quoted := format == OutputFormatCSV + + buffered := bufio.NewReaderSize(reader, lineEndingReadSize) + var crlf, lf int + inQuotes := false + prevCR := false + for { + b, err := buffered.ReadByte() + if err != nil { + if err == io.EOF { + break + } + return LineEndingLF, err + } + + switch { + case quoted && b == '"': + // A doubled quote inside a field is an escaped quote, which this + // toggling handles on its own: the pair leaves the state as it found it. + inQuotes = !inQuotes + case b == '\n' && !inQuotes: + if prevCR { + crlf++ + } else { + lf++ + } + } + prevCR = b == '\r' + } + if crlf > lf { - return LineEndingCRLF + return LineEndingCRLF, nil } - return LineEndingLF + return LineEndingLF, nil } diff --git a/line_ending_test.go b/line_ending_test.go index 8ad71f0..acff0c9 100644 --- a/line_ending_test.go +++ b/line_ending_test.go @@ -2,9 +2,13 @@ package filesql import ( "compress/gzip" + "errors" + "io" "os" "path/filepath" + "strings" "testing" + "testing/iotest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,11 +34,11 @@ func TestLineEnding_Terminator(t *testing.T) { assert.Equal(t, "\n", LineEnding(9).terminator()) } -// TestDominantLineEnding covers the rule that decides a file's terminator. The +// TestCountLineEndings covers the rule that decides a file's terminator. The // majority wins so that a file with one stray ending keeps the one the rest of // its lines use — rewriting those lines is the loss this whole feature exists to // prevent. -func TestDominantLineEnding(t *testing.T) { +func TestCountLineEndings(t *testing.T) { t.Parallel() tests := []struct { @@ -50,11 +54,30 @@ func TestDominantLineEnding(t *testing.T) { {"no line ending at all", "id,v", LineEndingLF}, {"nothing at all", "", LineEndingLF}, {"a lone carriage return is not a terminator", "id,v\r1,a\n", LineEndingLF}, + { + // The shape a spreadsheet export has: CRLF between records, and a value + // carrying its own line breaks inside quotes. + name: "a quoted line break is not a terminator", + sample: "id,note\r\n1,\"line\nline\nline\nline\"\r\n2,b\r\n", + want: LineEndingCRLF, + }, + { + name: "a quoted CRLF does not make an LF file CRLF", + sample: "id,note\n1,\"line\r\nline\r\nline\"\n2,b\n", + want: LineEndingLF, + }, + { + name: "a doubled quote inside a field leaves the count alone", + sample: "id,note\r\n1,\"say \"\"hi\"\"\"\r\n2,b\r\n", + want: LineEndingCRLF, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, dominantLineEnding([]byte(tt.sample))) + got, err := countLineEndings(strings.NewReader(tt.sample), OutputFormatCSV) + require.NoError(t, err) + assert.Equal(t, tt.want, got) }) } } @@ -73,8 +96,8 @@ func TestDetectLineEnding(t *testing.T) { require.NoError(t, os.WriteFile(crlf, []byte("id,v\r\n1,a\r\n"), 0o600)) require.NoError(t, os.WriteFile(lf, []byte("id,v\n1,a\n"), 0o600)) - assert.Equal(t, LineEndingCRLF, detectLineEnding(crlf)) - assert.Equal(t, LineEndingLF, detectLineEnding(lf)) + assert.Equal(t, LineEndingCRLF, detectLineEnding(crlf, OutputFormatCSV)) + assert.Equal(t, LineEndingLF, detectLineEnding(lf, OutputFormatCSV)) }) t.Run("a compressed file is read through its codec", func(t *testing.T) { @@ -89,14 +112,84 @@ func TestDetectLineEnding(t *testing.T) { require.NoError(t, gz.Close()) require.NoError(t, file.Close()) - assert.Equal(t, LineEndingCRLF, detectLineEnding(path), + assert.Equal(t, LineEndingCRLF, detectLineEnding(path, OutputFormatCSV), "the terminator is in the compressed bytes, so the codec has to be undone to see it") }) t.Run("a file that cannot be read answers with the default", func(t *testing.T) { t.Parallel() - assert.Equal(t, LineEndingLF, detectLineEnding(filepath.Join(t.TempDir(), "missing.csv")), + assert.Equal(t, LineEndingLF, detectLineEnding(filepath.Join(t.TempDir(), "missing.csv"), OutputFormatCSV), "detection is on the destination's behalf and must not fail the save") }) } + +// TestCountLineEndings_QuotesAreOnlyCSV pins that a quote is honored for CSV +// alone. TSV and LTSV define no quoting, so a quote there is data: honoring it +// would let one unmatched quote swallow every terminator after it. +func TestCountLineEndings_QuotesAreOnlyCSV(t *testing.T) { + t.Parallel() + + // A single quote, then CRLF records. Read as CSV the quote opens a field that + // never closes; read as TSV it is just a character in a value. + const sample = "id\tnote\r\n1\ta \" quote\r\n2\tb\r\n" + + got, err := countLineEndings(strings.NewReader(sample), OutputFormatTSV) + require.NoError(t, err) + assert.Equal(t, LineEndingCRLF, got, "a quote is data in TSV") + + got, err = countLineEndings(strings.NewReader(sample), OutputFormatLTSV) + require.NoError(t, err) + assert.Equal(t, LineEndingCRLF, got, "a quote is data in LTSV") +} + +// TestCountLineEndings_CountsPastTheFirstBuffer checks that the whole file +// decides, not its beginning. A file whose first part is CRLF and whose bulk is +// LF is an LF file, and stopping at a fixed prefix would answer the opposite and +// rewrite every one of those LF lines. +func TestCountLineEndings_CountsPastTheFirstBuffer(t *testing.T) { + t.Parallel() + + var b strings.Builder + for range lineEndingReadSize { + b.WriteString("a\r\n") + } + for range lineEndingReadSize * 2 { + b.WriteString("a\n") + } + + got, err := countLineEndings(strings.NewReader(b.String()), OutputFormatCSV) + require.NoError(t, err) + assert.Equal(t, LineEndingLF, got, "the majority over the file wins, not over its first buffer") +} + +// TestCountLineEndings_PartialReadIsNotEvidence covers a stream that fails +// partway. Half a file does not say what the whole one uses, so the failure is +// reported and the caller falls back to the default rather than acting on the +// part it managed to read. +func TestCountLineEndings_PartialReadIsNotEvidence(t *testing.T) { + t.Parallel() + + wantErr := errors.New("disk went away") + reader := io.MultiReader( + strings.NewReader("id,v\r\n1,a\r\n"), + iotest.ErrReader(wantErr), + ) + + _, err := countLineEndings(reader, OutputFormatCSV) + assert.ErrorIs(t, err, wantErr) +} + +// TestDetectLineEnding_UnreadableFileKeepsTheDefault is the same rule at the +// level a save uses: detection is an improvement on the destination's behalf and +// must never fail the save. +func TestDetectLineEnding_UnreadableFileKeepsTheDefault(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + truncated := filepath.Join(dir, "broken.csv.gz") + // Bytes that are not a gzip member: the codec fails before any line is seen. + require.NoError(t, os.WriteFile(truncated, []byte("id,v\r\n1,a\r\n"), 0o600)) + + assert.Equal(t, LineEndingLF, detectLineEnding(truncated, OutputFormatCSV)) +} diff --git a/memory_fallback_test.go b/memory_fallback_test.go index 6d107a7..373f0c6 100644 --- a/memory_fallback_test.go +++ b/memory_fallback_test.go @@ -56,9 +56,11 @@ func TestMemoryLimit_ShouldReduceChunkSizeUnderPressure(t *testing.T) { t.Run("exceeded cuts the chunk to a quarter", func(t *testing.T) { t.Parallel() limit := newMemoryLimit(defaultMemoryLimit) - // A limit at the heap this process already holds is exceeded however the - // heap moves afterwards, because it can only grow past it. - limit.maxMemoryMB = limit.getMemoryInfo().currentMB + // A limit of zero is exceeded by any heap at all, so the answer does not + // depend on what the heap happens to be doing while the test runs. Reading + // the current usage and setting the limit to it is not the same: a + // collection between the two readings puts the heap back under it. + limit.maxMemoryMB = 0 require.Equal(t, memoryStatusExceeded, limit.checkMemoryUsage()) shouldReduce, size := limit.shouldReduceChunkSize(1000) diff --git a/save.go b/save.go index 1afb60e..401acae 100644 --- a/save.go +++ b/save.go @@ -616,7 +616,7 @@ func (c *autoSaveConnection) overwriteOriginalFile(ctx context.Context, db *sql. options := DumpOptions{ Format: format, Compression: factory.DetectCompressionType(path), - LineEnding: detectLineEnding(path), + LineEnding: detectLineEnding(path, format), } // An Excel workbook holds a table per sheet, so all of them are written back From 4238f8fd0250de27a749caf1fdbdd5adb13d6c9f Mon Sep 17 00:00:00 2001 From: Naohiro CHIKAMATSU Date: Thu, 13 Aug 2026 18:19:34 +0900 Subject: [PATCH 5/5] test: assert the string a String method answers rather than the constant behind it --- .golangci.yml | 4 ++++ line_ending.go | 2 +- line_ending_test.go | 2 +- parser/parquet.go | 1 - parser/parser.go | 1 - prep/parser.go | 1 - prep/prep.go | 1 - save_encoding.go | 6 +----- save_encoding_unit_test.go | 2 +- 9 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 02ddcbe..44c31ff 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -50,6 +50,10 @@ linters: - wastedassign - whitespace settings: + goconst: + # A literal repeated across test cases is not a constant the package is + # missing, and counting them pushed production strings over the threshold. + ignore-tests: true errcheck: disable-default-exclusions: false check-type-assertions: true diff --git a/line_ending.go b/line_ending.go index ac529d2..9be9cac 100644 --- a/line_ending.go +++ b/line_ending.go @@ -31,7 +31,7 @@ func (l LineEnding) String() string { case LineEndingCRLF: return "crlf" default: - return unknownName + return "unknown" } } diff --git a/line_ending_test.go b/line_ending_test.go index acff0c9..9af1c81 100644 --- a/line_ending_test.go +++ b/line_ending_test.go @@ -20,7 +20,7 @@ func TestLineEnding_String(t *testing.T) { assert.Equal(t, "lf", LineEndingLF.String()) assert.Equal(t, "crlf", LineEndingCRLF.String()) - assert.Equal(t, unknownName, LineEnding(9).String()) + assert.Equal(t, "unknown", LineEnding(9).String()) } // TestLineEnding_Terminator covers the bytes each value writes, including a diff --git a/parser/parquet.go b/parser/parquet.go index 0496736..90d0f41 100644 --- a/parser/parquet.go +++ b/parser/parquet.go @@ -1,4 +1,3 @@ -//nolint:goconst // Arrow scalar conversions are easier to read with inline literal outputs. package parser import ( diff --git a/parser/parser.go b/parser/parser.go index 202034b..7e3df50 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1,4 +1,3 @@ -//nolint:goconst // File type/string tables intentionally repeat canonical tokens. package parser import ( diff --git a/prep/parser.go b/prep/parser.go index bcc3a5c..70ef4b2 100644 --- a/prep/parser.go +++ b/prep/parser.go @@ -1,4 +1,3 @@ -//nolint:goconst // Tag parsing intentionally matches literal option names for readability. package prep import ( diff --git a/prep/prep.go b/prep/prep.go index f72dbeb..963f143 100644 --- a/prep/prep.go +++ b/prep/prep.go @@ -1,4 +1,3 @@ -//nolint:goconst // Preprocessor coercion tokens intentionally stay literal for readability. package prep import ( diff --git a/save_encoding.go b/save_encoding.go index 029c026..8f08757 100644 --- a/save_encoding.go +++ b/save_encoding.go @@ -35,10 +35,6 @@ const ( EncodingUTF16BE ) -// unknownName is what a String method answers for a value outside the set its -// type defines, which a caller can produce by converting an int. -const unknownName = "unknown" - // String returns the name a user types for the encoding. func (e Encoding) String() string { switch e { @@ -55,7 +51,7 @@ func (e Encoding) String() string { case EncodingUTF16BE: return "utf-16be" default: - return unknownName + return "unknown" } } diff --git a/save_encoding_unit_test.go b/save_encoding_unit_test.go index 85dc920..5d53399 100644 --- a/save_encoding_unit_test.go +++ b/save_encoding_unit_test.go @@ -36,7 +36,7 @@ func TestEncoding_String(t *testing.T) { {EncodingISO2022JP, nameISO2022JP}, {EncodingUTF16LE, nameUTF16LE}, {EncodingUTF16BE, nameUTF16BE}, - {Encoding(99), unknownName}, + {Encoding(99), "unknown"}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) {