jsonstat-io 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +200 -0
  3. package/dist/arrow/index.cjs +60 -0
  4. package/dist/arrow/index.cjs.map +1 -0
  5. package/dist/arrow/index.d.cts +125 -0
  6. package/dist/arrow/index.d.ts +125 -0
  7. package/dist/arrow/index.js +3 -0
  8. package/dist/arrow/index.js.map +1 -0
  9. package/dist/arrowToCube-BdRWZ5oG.d.cts +29 -0
  10. package/dist/arrowToCube-Mjr79ZHM.d.ts +29 -0
  11. package/dist/chunk-2HY7NRKX.js +259 -0
  12. package/dist/chunk-2HY7NRKX.js.map +1 -0
  13. package/dist/chunk-62NIY7ML.js +191 -0
  14. package/dist/chunk-62NIY7ML.js.map +1 -0
  15. package/dist/chunk-AXUZF6O5.cjs +352 -0
  16. package/dist/chunk-AXUZF6O5.cjs.map +1 -0
  17. package/dist/chunk-EFRDFQOT.js +335 -0
  18. package/dist/chunk-EFRDFQOT.js.map +1 -0
  19. package/dist/chunk-GDKDYLZ4.cjs +263 -0
  20. package/dist/chunk-GDKDYLZ4.cjs.map +1 -0
  21. package/dist/chunk-IDTQZG4B.js +432 -0
  22. package/dist/chunk-IDTQZG4B.js.map +1 -0
  23. package/dist/chunk-KFIKGBIJ.cjs +200 -0
  24. package/dist/chunk-KFIKGBIJ.cjs.map +1 -0
  25. package/dist/chunk-L65CRXTV.js +64 -0
  26. package/dist/chunk-L65CRXTV.js.map +1 -0
  27. package/dist/chunk-PHLCWTHB.cjs +446 -0
  28. package/dist/chunk-PHLCWTHB.cjs.map +1 -0
  29. package/dist/chunk-VQCS4LVD.cjs +166 -0
  30. package/dist/chunk-VQCS4LVD.cjs.map +1 -0
  31. package/dist/chunk-VYS7DPB3.js +162 -0
  32. package/dist/chunk-VYS7DPB3.js.map +1 -0
  33. package/dist/chunk-ZEDTWRDN.cjs +70 -0
  34. package/dist/chunk-ZEDTWRDN.cjs.map +1 -0
  35. package/dist/cli/index.cjs +337 -0
  36. package/dist/cli/index.cjs.map +1 -0
  37. package/dist/cli/index.d.cts +90 -0
  38. package/dist/cli/index.d.ts +90 -0
  39. package/dist/cli/index.js +333 -0
  40. package/dist/cli/index.js.map +1 -0
  41. package/dist/cubeBuilder-BEGA4C5Y.js +4 -0
  42. package/dist/cubeBuilder-BEGA4C5Y.js.map +1 -0
  43. package/dist/cubeBuilder-ZU76VVEX.cjs +25 -0
  44. package/dist/cubeBuilder-ZU76VVEX.cjs.map +1 -0
  45. package/dist/cubeReader-7QVUF3TI.js +4 -0
  46. package/dist/cubeReader-7QVUF3TI.js.map +1 -0
  47. package/dist/cubeReader-UN2BHQWK.cjs +21 -0
  48. package/dist/cubeReader-UN2BHQWK.cjs.map +1 -0
  49. package/dist/index.cjs +172 -0
  50. package/dist/index.cjs.map +1 -0
  51. package/dist/index.d.cts +445 -0
  52. package/dist/index.d.ts +445 -0
  53. package/dist/index.js +7 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/ir-BArfNxo-.d.cts +143 -0
  56. package/dist/ir-Jae2ymov.d.ts +143 -0
  57. package/dist/jsonstat-BX4mBLci.d.cts +179 -0
  58. package/dist/jsonstat-BX4mBLci.d.ts +179 -0
  59. package/dist/sources/csv.cjs +40 -0
  60. package/dist/sources/csv.cjs.map +1 -0
  61. package/dist/sources/csv.d.cts +112 -0
  62. package/dist/sources/csv.d.ts +112 -0
  63. package/dist/sources/csv.js +3 -0
  64. package/dist/sources/csv.js.map +1 -0
  65. package/dist/sources/csvw.cjs +236 -0
  66. package/dist/sources/csvw.cjs.map +1 -0
  67. package/dist/sources/csvw.d.cts +143 -0
  68. package/dist/sources/csvw.d.ts +143 -0
  69. package/dist/sources/csvw.js +230 -0
  70. package/dist/sources/csvw.js.map +1 -0
  71. package/dist/sources/duckdb.cjs +121 -0
  72. package/dist/sources/duckdb.cjs.map +1 -0
  73. package/dist/sources/duckdb.d.cts +111 -0
  74. package/dist/sources/duckdb.d.ts +111 -0
  75. package/dist/sources/duckdb.js +115 -0
  76. package/dist/sources/duckdb.js.map +1 -0
  77. package/dist/sources/parquet.cjs +117 -0
  78. package/dist/sources/parquet.cjs.map +1 -0
  79. package/dist/sources/parquet.d.cts +83 -0
  80. package/dist/sources/parquet.d.ts +83 -0
  81. package/dist/sources/parquet.js +111 -0
  82. package/dist/sources/parquet.js.map +1 -0
  83. package/dist/sources/polars.cjs +94 -0
  84. package/dist/sources/polars.cjs.map +1 -0
  85. package/dist/sources/polars.d.cts +91 -0
  86. package/dist/sources/polars.d.ts +91 -0
  87. package/dist/sources/polars.js +87 -0
  88. package/dist/sources/polars.js.map +1 -0
  89. package/docs/api.md +322 -0
  90. package/docs/architecture.md +140 -0
  91. package/docs/cli.md +159 -0
  92. package/docs/formats/arrow.md +111 -0
  93. package/docs/formats/csv.md +115 -0
  94. package/docs/formats/csvw.md +152 -0
  95. package/docs/formats/duckdb.md +151 -0
  96. package/docs/formats/parquet.md +132 -0
  97. package/docs/formats/polars.md +111 -0
  98. package/docs/mapping.md +136 -0
  99. package/package.json +119 -0
@@ -0,0 +1,111 @@
1
+ # Polars Format
2
+
3
+ Convert a Polars `DataFrame` (from [`nodejs-polars`](https://pola-rs.github.io/nodejs-polars/)) to JSON-stat via the [Arrow hub](./arrow.md). Polars DataFrames expose `toArrow()` / `toIPC()`, which produce Apache Arrow tables. On export, the Arrow table is converted back to a Polars DataFrame via `fromArrow()`.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install jsonstat-io nodejs-polars
9
+ ```
10
+
11
+ `nodejs-polars` is an **optional peer dependency**. It is a native Node module — **Node only**. It cannot run in the browser.
12
+
13
+ ## Import path
14
+
15
+ ```ts
16
+ import { polarsToCube, polarsToDataset, loadPolars } from "jsonstat-io/polars";
17
+ ```
18
+
19
+ The Polars adapter is **not** available via the CLI for import — it requires a live DataFrame object.
20
+
21
+ ## Import example
22
+
23
+ ```ts
24
+ import { polarsToDataset, loadPolars } from "jsonstat-io/polars";
25
+
26
+ const pl = await loadPolars();
27
+ const df = pl.readCSV("sales.csv");
28
+
29
+ const dataset = await polarsToDataset(df, {
30
+ measure: "amount",
31
+ roles: { time: ["year"], geo: ["country"] },
32
+ });
33
+ ```
34
+
35
+ `loadPolars()` lazily imports `nodejs-polars` so it never enters a bundle that doesn't use it. You can also import `nodejs-polars` directly and pass the DataFrame.
36
+
37
+ ## Reading Parquet via Polars
38
+
39
+ Polars can read Parquet natively (often faster than `parquet-wasm` in Node):
40
+
41
+ ```ts
42
+ import { polarsToDataset, loadPolars } from "jsonstat-io/polars";
43
+
44
+ const pl = await loadPolars();
45
+ const df = pl.readParquet("sales.parquet");
46
+ const dataset = await polarsToDataset(df);
47
+ ```
48
+
49
+ ## Export path
50
+
51
+ [`cubeToPolars`](../../src/sources/polars.ts) converts the `Observations` IR to a Polars `DataFrame`: it calls `cubeToArrow` to build the Arrow table, then `pl.fromArrow(table)` to materialize the DataFrame.
52
+
53
+ ```ts
54
+ import { exportDataset } from "jsonstat-io";
55
+
56
+ // JSON-stat → Polars DataFrame
57
+ const df = await exportDataset(dataset, { to: "polars" });
58
+ ```
59
+
60
+ Or use `cubeToPolars` directly on the IR:
61
+
62
+ ```ts
63
+ import { cubeToPolars, loadPolars } from "jsonstat-io/polars";
64
+
65
+ await loadPolars(); // ensure nodejs-polars is imported
66
+ const df = await cubeToPolars(obs);
67
+ ```
68
+
69
+ ## Options
70
+
71
+ ### `PolarsToCubeOptions` (import)
72
+
73
+ Extends [`ArrowToCubeOptions`](./arrow.md):
74
+
75
+ | Field | Type | Description |
76
+ |--------------|-----------------------------------|-------------|
77
+ | `arrowMethod`| `"toArrow" \| "toIPC" \| "auto"` | Force a specific Arrow conversion method. Default `"auto"` tries `toArrow()` then `toIPC()`. |
78
+ | `measure` | `string` | Measure column name |
79
+ | `dimensions` | `string[]` | Dimension column names |
80
+ | `roles` | `RoleMap` | Role assignments |
81
+
82
+ ### `CubeToPolarsOptions` (export)
83
+
84
+ | Field | Type | Default | Description |
85
+ |---------|---------------------------------------|---------|-------------|
86
+ | `method`| `"fromArrow" \| "ipc" \| "auto"` | `"auto"`| Conversion method: `pl.fromArrow(table)` or round-trip through IPC. |
87
+
88
+ ## How it works
89
+
90
+ **Import:**
91
+ 1. `polarsToCube` extracts an Arrow Table from the DataFrame, trying `toArrow()` first, then `toIPC()` (decoded via `tableFromIPC`).
92
+ 2. Passes the table to `arrowToCube` → `Observations` IR.
93
+
94
+ **Export:**
95
+ 1. `cubeToPolars` calls `cubeToArrow(obs)` → Apache Arrow `Table`.
96
+ 2. Converts via `pl.fromArrow(table)` (or round-trips through IPC if `fromArrow` is unavailable).
97
+
98
+ If `nodejs-polars` is not installed or the DataFrame has no `toArrow()`/`toIPC()` method, a `PolarsSourceError` is thrown.
99
+
100
+ ## Browser alternative
101
+
102
+ For browser Polars data, convert to Arrow IPC first (Polars can serialize to IPC), then use the Arrow hub directly:
103
+
104
+ ```ts
105
+ import { arrowToDataset } from "jsonstat-io";
106
+ import { tableFromIPC } from "apache-arrow";
107
+
108
+ const ipcBytes = /* obtain Arrow IPC bytes */;
109
+ const table = tableFromIPC(ipcBytes);
110
+ const dataset = arrowToDataset(table);
111
+ ```
@@ -0,0 +1,136 @@
1
+ # Spec-Fidelity Mapping
2
+
3
+ This is the **correctness contract**: how every JSON-stat 2.0 concept maps to/from the columnar world and the internal Observations IR. If a round-trip loses information, it is a bug.
4
+
5
+ ## JSON-stat ↔ Observations IR
6
+
7
+ | JSON-stat concept | Observations IR field | Notes |
8
+ |----------------------------|-------------------------------|-------|
9
+ | `dataset.id` | `meta.label` / `dimensions` | Built from dimension IDs in `id[]` order |
10
+ | `dataset.size` | *(computed)* | Product of resolved category counts; not stored in IR |
11
+ | `dataset.dimension[id]` | `dimensions[id]` | One `DimensionColumn` per dimension |
12
+ | `dimension.category.index` | *(resolved position map)* | Built by `resolveDimension` — explicit order or first-seen |
13
+ | `dimension.category.label` | `DimensionColumn.categoryLabels` | `{id: label}` |
14
+ | `dimension.label` | `DimensionColumn.label` | |
15
+ | `dataset.value` (array) | `measure.values` scattered | Dense form: full row-major array |
16
+ | `dataset.value` (object) | `measure.values` scattered | Sparse form: `{position: value}` |
17
+ | `dataset.status` | `status.values` | Per-row status, deduplicated on emit |
18
+ | `dataset.role` | `roles` | `{time?: [], geo?: [], metric?: []}` |
19
+ | `dimension.category.unit` | `DimensionColumn.categoryUnits` | `{id: {decimals, label}}` |
20
+ | `dimension.category.coordinates` | `DimensionColumn.categoryCoords` | `{id: [lon, lat]}` |
21
+ | `dimension.category.child` | `DimensionColumn.categoryChild` | `{parent: [child, ...]}` |
22
+ | `dimension.category.order` | `DimensionColumn.categoryOrder` | Explicit `[id, ...]` ordering |
23
+
24
+ ## Arrow schema metadata contract
25
+
26
+ Producers annotate Arrow `Field` and `Schema` metadata with `jsonstat.*` keys so `arrowToCube` can reconstruct the full model losslessly. Without metadata, the converter uses heuristics (dictionary = dimension, first numeric = measure).
27
+
28
+ ### Field-level keys (`Field.metadata`)
29
+
30
+ | Key | Value | Meaning |
31
+ |---------------------------|------------------------------------|---------|
32
+ | `jsonstat.role` | `"time"` / `"geo"` / `"metric"` | Dimension role |
33
+ | `jsonstat.measure` | `"true"` | Marks this column as the measure (overrides type heuristics) |
34
+ | `jsonstat.status` | `"true"` | Marks this column as the per-row status column |
35
+ | `jsonstat.label` | string | Dimension label |
36
+ | `jsonstat.categoryLabels` | JSON object: `{id: label}` | Category labels |
37
+ | `jsonstat.categoryUnits` | JSON object: `{id: {decimals,...}}`| Per-category unit metadata (metric role) |
38
+ | `jsonstat.categoryCoords` | JSON object: `{id: [lon,lat]}` | Per-category coordinates (geo role) |
39
+ | `jsonstat.categoryChild` | JSON object: `{parent: [child,...]}`| Hierarchy |
40
+ | `jsonstat.categoryOrder` | JSON array: `[id,...]` | Explicit category ordering (e.g. chronological) |
41
+
42
+ ### Schema-level keys (`Schema.metadata`)
43
+
44
+ | Key | Value | Meaning |
45
+ |----------------------|--------------------------------|---------|
46
+ | `jsonstat.label` | string | Dataset label |
47
+ | `jsonstat.source` | string | Dataset source |
48
+ | `jsonstat.updated` | ISO 8601 string | Dataset update time |
49
+ | `jsonstat.extension` | JSON object | Provider extension data |
50
+ | `jsonstat.valueForm` | `"auto"`/`"dense"`/`"sparse"` | Value emission hint |
51
+
52
+ ## Arrow type → JSON-stat mapping
53
+
54
+ | Arrow type | JSON-stat role | Notes |
55
+ |-----------------------------|----------------|-------|
56
+ | `Dictionary<Utf8, Int*>` | dimension | Natural fit — dictionary-encoded strings |
57
+ | `Utf8` (non-dictionary) | dimension | Stringified |
58
+ | `Bool` | dimension | → `"true"`/`"false"` |
59
+ | `Int32`, `Int64` | dimension or measure | Dimension if not the detected/declared measure |
60
+ | `Float32`, `Float64` | measure (default) | First numeric column is the measure heuristic |
61
+ | `DateDay`, `DateMillisecond`| dimension (time) | ISO date string |
62
+ | `TimestampSecond/Millisecond`| dimension (time)| ISO 8601 string |
63
+ | Other types | dimension | Stringified via `String(vec.get(i))` |
64
+
65
+ **Detection order** (when metadata is absent):
66
+ 1. If `options.measure` is set, that column is the measure.
67
+ 2. Otherwise, the first `Float64`/`Float32`/`Int64`/`Int32` column is the measure.
68
+ 3. A column named `status` (case-insensitive) is the status column.
69
+ 4. All remaining columns are dimensions, in schema order.
70
+
71
+ **Roles** (when metadata is absent):
72
+ - A dimension named `year`, `date`, `time`, or `period` (case-insensitive) gets the `time` role.
73
+ - A dimension named `country`, `region`, `geo`, or `area` gets the `geo` role.
74
+ - The measure column gets the `metric` role.
75
+ - Explicit `options.roles` merge with and take precedence over both metadata and heuristics.
76
+
77
+ ## Row-major value ordering
78
+
79
+ JSON-stat stores values in **row-major** order: the first dimension varies slowest, the last dimension varies fastest. For dimensions `[sex, age, year]` with sizes `[2, 2, 2]`:
80
+
81
+ ```
82
+ Position 0: sex[0], age[0], year[0]
83
+ Position 1: sex[0], age[0], year[1]
84
+ Position 2: sex[0], age[1], year[0]
85
+ Position 3: sex[0], age[1], year[1]
86
+ Position 4: sex[1], age[0], year[0]
87
+ Position 5: sex[1], age[0], year[1]
88
+ Position 6: sex[1], age[1], year[0]
89
+ Position 7: sex[1], age[1], year[1]
90
+ ```
91
+
92
+ The stride of dimension `d` is the product of all subsequent sizes:
93
+
94
+ ```
95
+ strides[sex] = size[age] × size[year] = 2 × 2 = 4
96
+ strides[age] = size[year] = 2
97
+ strides[year] = 1
98
+ ```
99
+
100
+ `flatPosition = Σ indices[d] × strides[d]`. The builder scatters each IR row's measure value into its computed position. `null` values are preserved in position (dense) or omitted (sparse).
101
+
102
+ ## Dense vs sparse value form
103
+
104
+ | Form | JSON shape | When used |
105
+ |--------|--------------------------|-----------|
106
+ | Dense | `"value": [10,20,...]` | Full array, including `null` holes |
107
+ | Sparse | `"value": {"0":10,...}` | Object with only present positions |
108
+
109
+ The `decideDensity` heuristic: if the null ratio exceeds the threshold (default `0.5`), emit sparse; otherwise dense. Override with `valueForm: "dense"` or `"sparse"`.
110
+
111
+ ## Status forms
112
+
113
+ JSON-stat allows three `status` representations:
114
+
115
+ | Form | JSON shape | When emitted |
116
+ |--------|-----------------------------|--------------|
117
+ | String | `"status": "p"` | Every row has the same status |
118
+ | Array | `"status": ["p","e",...]` | Per-cell status, mixed |
119
+ | (none) | *(omitted)* | No status column present |
120
+
121
+ `statusForm: "object"` is also available to force the explicit object form `{position: code}` regardless of uniformity.
122
+
123
+ ## CSVW column mapping
124
+
125
+ CSVW metadata declares column types and roles explicitly:
126
+
127
+ | CSVW property | Maps to |
128
+ |----------------------------------|----------------------------------|
129
+ | `tableSchema.columns[].titles` | Column name (first title) |
130
+ | `tableSchema.columns[].datatype` | Measure if `decimal`/`integer`/`double` |
131
+ | `tableSchema.columns[].propertyUrl` containing `#time` / `#geo` | Role |
132
+ | `tableSchema.primaryKey` | Dimension columns (if no explicit dims) |
133
+
134
+ ## Round-trip guarantee
135
+
136
+ The test suite verifies that `readDataset` (JSON-stat → IR) followed by `buildDataset` (IR → JSON-stat) reproduces the original dataset's values, dimensions, roles, and status. The same guarantee holds for `arrowToCube` → `cubeToArrow` on the Arrow side, and for the full export round-trip: `export → bytes → import` returns the original dataset's values, dimensions, roles, and status. Any information loss in these round-trips is considered a bug.
package/package.json ADDED
@@ -0,0 +1,119 @@
1
+ {
2
+ "name": "jsonstat-io",
3
+ "version": "0.1.0",
4
+ "description": "Bidirectional bridge between the columnar stack (Apache Arrow, Parquet, DuckDB, Polars, CSVW, CSV) and JSON-stat 2.0 cubes. An Arrow-hubbed interop layer for the lakehouse ecosystem.",
5
+ "license": "Apache-2.0",
6
+ "author": "Xavier Badosa",
7
+ "homepage": "https://github.com/jsonstat/io#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/jsonstat/io.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/jsonstat/io/issues"
14
+ },
15
+ "keywords": [
16
+ "json-stat",
17
+ "jsonstat",
18
+ "arrow",
19
+ "apache-arrow",
20
+ "parquet",
21
+ "duckdb",
22
+ "polars",
23
+ "csvw",
24
+ "statistical-data",
25
+ "cube",
26
+ "lakehouse",
27
+ "interop"
28
+ ],
29
+ "type": "module",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "require": "./dist/index.cjs"
38
+ },
39
+ "./arrow": {
40
+ "types": "./dist/arrow/index.d.ts",
41
+ "import": "./dist/arrow/index.js",
42
+ "require": "./dist/arrow/index.cjs"
43
+ },
44
+ "./parquet": {
45
+ "types": "./dist/sources/parquet.d.ts",
46
+ "import": "./dist/sources/parquet.js",
47
+ "require": "./dist/sources/parquet.cjs"
48
+ },
49
+ "./duckdb": {
50
+ "types": "./dist/sources/duckdb.d.ts",
51
+ "import": "./dist/sources/duckdb.js",
52
+ "require": "./dist/sources/duckdb.cjs"
53
+ },
54
+ "./polars": {
55
+ "types": "./dist/sources/polars.d.ts",
56
+ "import": "./dist/sources/polars.js",
57
+ "require": "./dist/sources/polars.cjs"
58
+ },
59
+ "./csvw": {
60
+ "types": "./dist/sources/csvw.d.ts",
61
+ "import": "./dist/sources/csvw.js",
62
+ "require": "./dist/sources/csvw.cjs"
63
+ },
64
+ "./csv": {
65
+ "types": "./dist/sources/csv.d.ts",
66
+ "import": "./dist/sources/csv.js",
67
+ "require": "./dist/sources/csv.cjs"
68
+ },
69
+ "./package.json": "./package.json"
70
+ },
71
+ "main": "./dist/index.cjs",
72
+ "module": "./dist/index.js",
73
+ "types": "./dist/index.d.ts",
74
+ "bin": {
75
+ "jsonstat-io": "./dist/cli/index.js"
76
+ },
77
+ "files": [
78
+ "dist",
79
+ "README.md",
80
+ "LICENSE",
81
+ "docs"
82
+ ],
83
+ "sideEffects": false,
84
+ "scripts": {
85
+ "build": "tsup",
86
+ "dev": "tsup --watch",
87
+ "typecheck": "tsc --noEmit",
88
+ "test": "vitest run",
89
+ "test:watch": "vitest",
90
+ "lint": "biome check .",
91
+ "format": "biome format --write .",
92
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
93
+ },
94
+ "dependencies": {
95
+ "apache-arrow": "^17.0.0",
96
+ "commander": "^12.1.0"
97
+ },
98
+ "optionalDependencies": {
99
+ "@duckdb/duckdb-wasm": "^1.29.0",
100
+ "duckdb-async": "^1.2.1",
101
+ "jsonstat-validator": "^2.5.0",
102
+ "nodejs-polars": "^0.15.0",
103
+ "parquet-wasm": "^0.6.1"
104
+ },
105
+ "peerDependenciesMeta": {
106
+ "@duckdb/duckdb-wasm": { "optional": true },
107
+ "duckdb-async": { "optional": true },
108
+ "jsonstat-validator": { "optional": true },
109
+ "nodejs-polars": { "optional": true },
110
+ "parquet-wasm": { "optional": true }
111
+ },
112
+ "devDependencies": {
113
+ "@biomejs/biome": "^1.9.4",
114
+ "@types/node": "^22.10.0",
115
+ "tsup": "^8.3.5",
116
+ "typescript": "^5.7.2",
117
+ "vitest": "^2.1.8"
118
+ }
119
+ }