From 38e4e8585966245d3913c2d82be0072b38a3f1ef Mon Sep 17 00:00:00 2001 From: Orion Kindel Date: Tue, 30 Apr 2024 14:09:12 -0500 Subject: [PATCH] fix: rename modules, add readme --- README.md | 81 ++++++++++++++ bun/fmt.js | 27 +++++ bun/prepare.js | 32 ++++++ spago.lock | 55 +++++----- spago.yaml | 1 + ...V.Readable.js => Node.Stream.CSV.Parse.js} | 0 ...adable.purs => Node.Stream.CSV.Parse.purs} | 15 ++- ...itable.js => Node.Stream.CSV.Stringify.js} | 0 src/Node.Stream.CSV.Stringify.purs | 100 ++++++++++++++++++ src/Node.Stream.CSV.Writable.purs | 60 ----------- 10 files changed, 281 insertions(+), 90 deletions(-) create mode 100644 README.md create mode 100644 bun/fmt.js create mode 100644 bun/prepare.js rename src/{Node.Stream.CSV.Readable.js => Node.Stream.CSV.Parse.js} (100%) rename src/{Node.Stream.CSV.Readable.purs => Node.Stream.CSV.Parse.purs} (85%) rename src/{Node.Stream.CSV.Writable.js => Node.Stream.CSV.Stringify.js} (100%) create mode 100644 src/Node.Stream.CSV.Stringify.purs delete mode 100644 src/Node.Stream.CSV.Writable.purs diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee047d8 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# purescript-csv-stream + +Type-safe bindings for the streaming API of `csv-parse` and `csv-stringify`. + +## Installing +```bash +spago install csv-stream +{bun|yarn|npm|pnpm} install csv-parse csv-stringify +``` + +## Examples +### Stream +```purescript +module Main where + +import Prelude + +import Effect (Effect) +import Effect.Class (liftEffect) +import Effect.Aff (launchAff_) +import Node.Stream (pipe) +import Node.Stream as Stream +import Node.Stream.CSV.Stringify as CSV.Stringify +import Node.Stream.CSV.Parse as CSV.Parse + +type MyCSVType1 = {a :: Int, b :: Int, bar :: String, baz :: Boolean} +type MyCSVType2 = {ab :: Int, bar :: String, baz :: Boolean} + +atob :: MyCSVType1 -> MyCSVType2 +atob {a, b, bar, baz} = {ab: a + b, bar, baz} + +myCSV :: String +myCSV = "a,b,bar,baz\n1,2,\"hello, world!\",true\n3,3,,f" + +main :: Effect Unit +main = launchAff_ do + parser <- liftEffect $ CSV.Parse.make {} + stringifier <- liftEffect $ CSV.Stringify.make {} + + input <- liftEffect $ Stream.readableFromString myCSV + liftEffect $ Stream.pipe input parser + + records <- CSV.Parse.readAll parser + liftEffect $ for_ records \r -> CSV.Stringify.write $ atob r + liftEffect $ Stream.end stringifier + + -- "ab,bar,baz\n3,\"hello, world!\",true\n6,,false" + csvString <- CSV.Stringify.readAll stringifier + pure unit +``` + +### Synchronous +```purescript +module Main where + +import Prelude + +import Effect (Effect) +import Effect.Class (liftEffect) +import Effect.Aff (launchAff_) +import Node.Stream (pipe) +import Node.Stream as Stream +import Node.Stream.CSV.Stringify as CSV.Stringify +import Node.Stream.CSV.Parse as CSV.Parse + +type MyCSVType1 = {a :: Int, b :: Int, bar :: String, baz :: Boolean} +type MyCSVType2 = {ab :: Int, bar :: String, baz :: Boolean} + +atob :: MyCSVType1 -> MyCSVType2 +atob {a, b, bar, baz} = {ab: a + b, bar, baz} + +myCSV :: String +myCSV = "a,b,bar,baz\n1,2,\"hello, world!\",true\n3,3,,f" + +main :: Effect Unit +main = launchAff_ do + records :: Array MyCSVType1 <- CSV.Parse.parse myCSV + -- "ab,bar,baz\n3,\"hello, world!\",true\n6,,false" + csvString <- CSV.Stringify.stringify (atob <$> records) + pure unit +``` diff --git a/bun/fmt.js b/bun/fmt.js new file mode 100644 index 0000000..04b5c40 --- /dev/null +++ b/bun/fmt.js @@ -0,0 +1,27 @@ +/** @type {(parser: string, ps: string[]) => import("bun").Subprocess} */ +const prettier = (parser, ps) => + Bun.spawn(['bun', 'x', 'prettier', '--write', '--parser', parser, ...ps], { + stdout: 'inherit', + stderr: 'inherit', + }) + +const procs = [ + prettier('babel', ['./src/**/*.js', './bun/**/*.js', './.prettierrc.cjs']), + prettier('json', ['./package.json', './jsconfig.json']), + Bun.spawn( + [ + 'bun', + 'x', + 'purs-tidy', + 'format-in-place', + 'src/**/*.purs', + 'test/**/*.purs', + ], + { + stdout: 'inherit', + stderr: 'inherit', + }, + ), +] + +await Promise.all(procs.map(p => p.exited)) diff --git a/bun/prepare.js b/bun/prepare.js new file mode 100644 index 0000000..a0c7ad9 --- /dev/null +++ b/bun/prepare.js @@ -0,0 +1,32 @@ +import { readFile, writeFile } from 'fs/promises' +import { execSync } from 'child_process' + +let ver = process.argv[2] +if (!ver) { + console.error(`tag required: bun bun/prepare.js v1.0.0`) +} else if (!/v\d+\.\d+\.\d+/.test(ver)) { + console.error(`invalid tag: ${ver}`) +} + +ver = (/\d+\.\d+\.\d+/.exec(ver) || [])[0] || '' + +const pkg = await readFile('./package.json', 'utf8') +const pkgnew = pkg.replace(/"version": ".+"/, `"version": "v${ver}"`) +await writeFile('./package.json', pkgnew) + +const spago = await readFile('./spago.yaml', 'utf8') +const spagonew = spago.replace(/version: .+/, `version: '${ver}'`) +await writeFile('./spago.yaml', spagonew) + +const readme = await readFile('./README.md', 'utf8') +const readmenew = readme.replace( + /packages\/purescript-postgresql\/.+?\//g, + `/packages/purescript-postgresql/${ver}/`, +) +await writeFile('./README.md', readmenew) + +execSync(`git add spago.yaml package.json README.md`) +execSync(`git commit -m 'chore: prepare v${ver}'`) +execSync(`git tag v${ver}`) +execSync(`git push --tags`) +execSync(`git push --mirror github-mirror`) diff --git a/spago.lock b/spago.lock index 3b9f93a..5a3c9a4 100644 --- a/spago.lock +++ b/spago.lock @@ -3,33 +3,34 @@ workspace: csv-stream: path: ./ dependencies: - - aff - - arrays - - bifunctors - - datetime - - effect - - either - - exceptions - - foldable-traversable - - foreign - - foreign-object - - integers - - lists - - maybe - - newtype - - node-event-emitter - - node-streams - - nullable - - numbers - - precise-datetime - - prelude - - record - - st - - strings - - tailrec - - transformers - - typelevel-prelude - - unsafe-coerce + - aff: ">=7.1.0 <8.0.0" + - arrays: ">=7.3.0 <8.0.0" + - bifunctors: ">=6.0.0 <7.0.0" + - datetime: ">=6.1.0 <7.0.0" + - effect: ">=4.0.0 <5.0.0" + - either: ">=6.1.0 <7.0.0" + - exceptions: ">=6.0.0 <7.0.0" + - foldable-traversable: ">=6.0.0 <7.0.0" + - foreign: ">=7.0.0 <8.0.0" + - foreign-object: ">=4.1.0 <5.0.0" + - integers: ">=6.0.0 <7.0.0" + - lists: ">=7.0.0 <8.0.0" + - maybe: ">=6.0.0 <7.0.0" + - newtype: ">=5.0.0 <6.0.0" + - node-buffer + - node-event-emitter: ">=3.0.0 <4.0.0" + - node-streams: ">=9.0.0 <10.0.0" + - nullable: ">=6.0.0 <7.0.0" + - numbers: ">=9.0.1 <10.0.0" + - precise-datetime: ">=7.0.0 <8.0.0" + - prelude: ">=6.0.1 <7.0.0" + - record: ">=4.0.0 <5.0.0" + - st: ">=6.2.0 <7.0.0" + - strings: ">=6.0.1 <7.0.0" + - tailrec: ">=6.1.0 <7.0.0" + - transformers: ">=6.0.0 <7.0.0" + - typelevel-prelude: ">=7.0.0 <8.0.0" + - unsafe-coerce: ">=6.0.0 <7.0.0" test_dependencies: - console build_plan: diff --git a/spago.yaml b/spago.yaml index dddf13e..61a4f91 100644 --- a/spago.yaml +++ b/spago.yaml @@ -10,6 +10,7 @@ package: strict: true pedanticPackages: true dependencies: + - node-buffer - aff: ">=7.1.0 <8.0.0" - arrays: ">=7.3.0 <8.0.0" - bifunctors: ">=6.0.0 <7.0.0" diff --git a/src/Node.Stream.CSV.Readable.js b/src/Node.Stream.CSV.Parse.js similarity index 100% rename from src/Node.Stream.CSV.Readable.js rename to src/Node.Stream.CSV.Parse.js diff --git a/src/Node.Stream.CSV.Readable.purs b/src/Node.Stream.CSV.Parse.purs similarity index 85% rename from src/Node.Stream.CSV.Readable.purs rename to src/Node.Stream.CSV.Parse.purs index f478ebd..80dd54c 100644 --- a/src/Node.Stream.CSV.Readable.purs +++ b/src/Node.Stream.CSV.Parse.purs @@ -1,4 +1,4 @@ -module Node.Stream.CSV.Readable where +module Node.Stream.CSV.Parse where import Prelude @@ -23,6 +23,7 @@ import Effect.Uncurried (mkEffectFn1) import Foreign (Foreign, unsafeToForeign) import Foreign.Object (Object) import Foreign.Object as Object +import Node.Encoding (Encoding(..)) import Node.EventEmitter (EventHandle(..)) import Node.EventEmitter as Event import Node.EventEmitter.UtilTypes (EventHandle1) @@ -74,9 +75,18 @@ type Config r = | r ) -make :: forall @r rl config missing extra. ReadCSVRecord r rl => Union config missing (Config extra) => { | config } -> Effect (CSVParser r ()) +-- | Create a CSVParser +make :: forall @r rl @config @missing @extra. RowToList r rl => ReadCSVRecord r rl => Union config missing (Config extra) => { | config } -> Effect (CSVParser r ()) make = makeImpl <<< unsafeToForeign <<< Object.union (recordToForeign {columns: true, cast: false, cast_date: false}) <<< recordToForeign +-- | Synchronously parse a CSV string +parse :: forall @r rl @config missing extra. RowToList r rl => ReadCSVRecord r rl => Union config missing (Config extra) => { | config } -> String -> Aff (Array { | r }) +parse config csv = do + stream <- liftEffect $ make @r @config @missing @extra config + void $ liftEffect $ Stream.writeString stream UTF8 csv + liftEffect $ Stream.end stream + readAll stream + -- | Reads a parsed record from the stream. -- | -- | Returns `Nothing` when either: @@ -118,4 +128,3 @@ foreign import readImpl :: forall r. Stream r -> Effect (Nullable (Array String) -- | FFI recordToForeign :: forall r. Record r -> Object Foreign recordToForeign = unsafeCoerce - diff --git a/src/Node.Stream.CSV.Writable.js b/src/Node.Stream.CSV.Stringify.js similarity index 100% rename from src/Node.Stream.CSV.Writable.js rename to src/Node.Stream.CSV.Stringify.js diff --git a/src/Node.Stream.CSV.Stringify.purs b/src/Node.Stream.CSV.Stringify.purs new file mode 100644 index 0000000..d4fda82 --- /dev/null +++ b/src/Node.Stream.CSV.Stringify.purs @@ -0,0 +1,100 @@ +module Node.Stream.CSV.Stringify where + +import Prelude + +import Control.Monad.Rec.Class (whileJust) +import Control.Monad.ST.Global as ST +import Data.Array.ST as Array.ST +import Data.CSV.Record (class WriteCSVRecord, writeCSVRecord) +import Data.Either (Either(..), blush) +import Data.Foldable (class Foldable, fold) +import Data.Maybe (Maybe(..)) +import Data.String.Regex (Regex) +import Data.Traversable (for_) +import Effect (Effect) +import Effect.Aff (Aff, makeAff) +import Effect.Class (liftEffect) +import Foreign (Foreign, unsafeToForeign) +import Foreign.Object (Object) +import Foreign.Object as Object +import Node.EventEmitter as Event +import Node.Stream (Read, Stream, Write) +import Node.Stream as Stream +import Prim.Row (class Union) +import Prim.RowList (class RowToList) +import Unsafe.Coerce (unsafeCoerce) + +data CSVWrite + +-- | Stream transforming rows of stringified CSV values +-- | to CSV-formatted rows. +-- | +-- | Write rows to the stream using `write`. +-- | +-- | Stringified rows are emitted on the `Readable` end as string +-- | chunks, meaning it can be treated as a `Node.Stream.Readable` +-- | that has had `setEncoding UTF8` invoked on it. +type CSVStringifier :: Row Type -> Row Type -> Type +type CSVStringifier a r = Stream (read :: Read, write :: Write, csv :: CSVWrite | r) + +-- | https://csv.js.org/stringify/options/ +type Config r = + ( bom :: Boolean + , group_columns_by_name :: Boolean + , delimiter :: String + , record_delimiter :: String + , escape :: String + , escape_formulas :: Boolean + , header :: Boolean + , quote :: String + , quoted :: Boolean + , quoted_empty :: Boolean + , quoted_match :: Regex + , quoted_string :: Boolean + | r + ) + +foreign import makeImpl :: forall r. Foreign -> Effect (Stream r) +foreign import writeImpl :: forall r. Stream r -> Array String -> Effect Unit + +recordToForeign :: forall r. Record r -> Object Foreign +recordToForeign = unsafeCoerce + +-- | Create a CSVStringifier +make :: forall @r rl @config @missing @extra. RowToList r rl => WriteCSVRecord r rl => Union config missing (Config extra) => { | config } -> Effect (CSVStringifier r ()) +make = makeImpl <<< unsafeToForeign <<< Object.union (recordToForeign {columns: true, cast: false, cast_date: false}) <<< recordToForeign + +-- | Synchronously stringify a collection of records +stringify :: forall @r rl f @config missing extra. Foldable f => RowToList r rl => WriteCSVRecord r rl => Union config missing (Config extra) => { | config } -> f { | r } -> Aff String +stringify config records = do + stream <- liftEffect $ make @r @config @missing @extra config + liftEffect $ for_ records \r -> write stream r + liftEffect $ Stream.end stream + readAll stream + +-- | Write a record to a CSVStringifier. +-- | +-- | The record will be emitted on the `Readable` end +-- | of the stream as a string chunk. +write :: forall @r rl a. RowToList r rl => WriteCSVRecord r rl => CSVStringifier r a -> { | r } -> Effect Unit +write s = writeImpl s <<< writeCSVRecord @r @rl + +-- | Read the stringified chunks until end-of-stream, returning the entire CSV string. +readAll :: forall r a. CSVStringifier r a -> Aff String +readAll stream = do + chunks <- liftEffect $ ST.toEffect $ Array.ST.new + + whileJust do + isReadable <- liftEffect $ Stream.readable stream + when (not isReadable) $ makeAff \res -> mempty <* flip (Event.on Stream.readableH) stream $ res $ Right unit + + liftEffect $ whileJust do + s <- (join <<< map blush) <$> Stream.readEither stream + for_ s \s' -> ST.toEffect $ Array.ST.push s' chunks + pure $ void s + + isClosed <- liftEffect $ Stream.closed stream + pure $ if isClosed then Nothing else Just unit + + chunks' <- liftEffect $ ST.toEffect $ Array.ST.unsafeFreeze chunks + pure $ fold chunks' diff --git a/src/Node.Stream.CSV.Writable.purs b/src/Node.Stream.CSV.Writable.purs deleted file mode 100644 index c9d75b8..0000000 --- a/src/Node.Stream.CSV.Writable.purs +++ /dev/null @@ -1,60 +0,0 @@ -module Node.Stream.CSV.Writable where - -import Prelude - -import Data.CSV.Record (class WriteCSVRecord, writeCSVRecord) -import Data.String.Regex (Regex) -import Effect (Effect) -import Foreign (Foreign, unsafeToForeign) -import Foreign.Object (Object) -import Foreign.Object as Object -import Node.Stream (Read, Stream, Write) -import Prim.Row (class Union) -import Unsafe.Coerce (unsafeCoerce) - -data CSVWrite - --- | Stream transforming rows of stringified CSV values --- | to CSV-formatted rows. --- | --- | Write rows to the stream using `write`. --- | --- | Stringified rows are emitted on the `Readable` end as string --- | chunks, meaning it can be treated as a `Node.Stream.Readable` --- | that has had `setEncoding UTF8` invoked on it. -type CSVStringifier :: Row Type -> Row Type -> Type -type CSVStringifier a r = Stream (read :: Read, write :: Write, csv :: CSVWrite | r) - --- | https://csv.js.org/stringify/options/ -type Config r = - ( bom :: Boolean - , group_columns_by_name :: Boolean - , delimiter :: String - , record_delimiter :: String - , escape :: String - , escape_formulas :: Boolean - , header :: Boolean - , quote :: String - , quoted :: Boolean - , quoted_empty :: Boolean - , quoted_match :: Regex - , quoted_string :: Boolean - | r - ) - -foreign import makeImpl :: forall r. Foreign -> Effect (Stream r) -foreign import writeImpl :: forall r. Stream r -> Array String -> Effect Unit - -recordToForeign :: forall r. Record r -> Object Foreign -recordToForeign = unsafeCoerce - --- | Create a CSVStringifier -make :: forall @r rl config missing extra. WriteCSVRecord r rl => Union config missing (Config extra) => { | config } -> Effect (CSVStringifier r ()) -make = makeImpl <<< unsafeToForeign <<< Object.union (recordToForeign {columns: true, cast: false, cast_date: false}) <<< recordToForeign - --- | Write a record to a CSVStringifier. --- | --- | The record will be emitted on the `Readable` end --- | of the stream as a string chunk. -write :: forall @r rl a. WriteCSVRecord r rl => CSVStringifier r a -> { | r } -> Effect Unit -write s = writeImpl s <<< writeCSVRecord @r @rl