@graphty/graph-io 0.3.40 → 0.3.42

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 (210) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/{children-CL3Cy0ez.js → children-CA4coBi0.js} +36 -27
  3. package/dist/chunks/children-CA4coBi0.js.map +1 -0
  4. package/dist/chunks/{choose-CS3fvGT1.js → choose-DvpbdRuz.js} +2 -2
  5. package/dist/chunks/{choose-CS3fvGT1.js.map → choose-DvpbdRuz.js.map} +1 -1
  6. package/dist/chunks/{constants-DqKZT6cH.js → constants-fT3E8JYY.js} +2 -2
  7. package/dist/chunks/{constants-DqKZT6cH.js.map → constants-fT3E8JYY.js.map} +1 -1
  8. package/dist/chunks/{escape-BJRLd3vJ.js → escape-B62ZGM_a.js} +2 -2
  9. package/dist/chunks/{escape-BJRLd3vJ.js.map → escape-B62ZGM_a.js.map} +1 -1
  10. package/dist/chunks/{export-B-aVCwyG.js → export-rKLeGQMo.js} +404 -243
  11. package/dist/chunks/export-rKLeGQMo.js.map +1 -0
  12. package/dist/chunks/{exporter-6ocL_9x_.js → exporter-8hLNemFk.js} +4 -4
  13. package/dist/chunks/{exporter-6ocL_9x_.js.map → exporter-8hLNemFk.js.map} +1 -1
  14. package/dist/chunks/{exporter-BVXnV9-9.js → exporter-BWwEuOPT.js} +29 -17
  15. package/dist/chunks/exporter-BWwEuOPT.js.map +1 -0
  16. package/dist/chunks/{exporter-Bcp-l4Bo.js → exporter-CpRyHZ3H.js} +574 -475
  17. package/dist/chunks/exporter-CpRyHZ3H.js.map +1 -0
  18. package/dist/chunks/{exporter-SFYkDzD0.js → exporter-yLxKBvLE.js} +23 -20
  19. package/dist/chunks/{exporter-SFYkDzD0.js.map → exporter-yLxKBvLE.js.map} +1 -1
  20. package/dist/chunks/{importer-DcKB97kW.js → importer-BbNb7eNg.js} +111 -71
  21. package/dist/chunks/importer-BbNb7eNg.js.map +1 -0
  22. package/dist/chunks/{importer-D_srHyj6.js → importer-C98dpSH4.js} +5 -5
  23. package/dist/chunks/{importer-D_srHyj6.js.map → importer-C98dpSH4.js.map} +1 -1
  24. package/dist/chunks/{importer-BJlGAOht.js → importer-D794XUTe.js} +3 -3
  25. package/dist/chunks/{importer-BJlGAOht.js.map → importer-D794XUTe.js.map} +1 -1
  26. package/dist/chunks/{importer-AjwOJrn2.js → importer-Dz7tI8Sa.js} +202 -181
  27. package/dist/chunks/importer-Dz7tI8Sa.js.map +1 -0
  28. package/dist/chunks/{importer-DEp_fYjf.js → importer-Ht7LNg8L.js} +92 -64
  29. package/dist/chunks/importer-Ht7LNg8L.js.map +1 -0
  30. package/dist/chunks/{importer-CPqnIY8m.js → importer-WrGRZb0N.js} +42 -29
  31. package/dist/chunks/importer-WrGRZb0N.js.map +1 -0
  32. package/dist/chunks/{importer-BpuS3J8C.js → importer-xZ-3suY1.js} +74 -61
  33. package/dist/chunks/importer-xZ-3suY1.js.map +1 -0
  34. package/dist/chunks/{json-elements-DpO6AXIc.js → json-elements-DKSqpNW1.js} +3 -3
  35. package/dist/chunks/{json-elements-DpO6AXIc.js.map → json-elements-DKSqpNW1.js.map} +1 -1
  36. package/dist/chunks/{ontology-export-50L9DOV4.js → ontology-export-CtRXjg_i.js} +2 -2
  37. package/dist/chunks/{ontology-export-50L9DOV4.js.map → ontology-export-CtRXjg_i.js.map} +1 -1
  38. package/dist/chunks/{records-r3SFHz11.js → records-Cu4bxYOm.js} +69 -36
  39. package/dist/chunks/records-Cu4bxYOm.js.map +1 -0
  40. package/dist/chunks/{text-Bjfa1xYL.js → text-DHOJXnED.js} +20 -11
  41. package/dist/chunks/text-DHOJXnED.js.map +1 -0
  42. package/dist/csv.js +323 -241
  43. package/dist/csv.js.map +1 -1
  44. package/dist/cx.js +1 -1
  45. package/dist/cx2.js +2 -2
  46. package/dist/cys.js +1 -1
  47. package/dist/dot.js +1 -1
  48. package/dist/gexf.js +195 -97
  49. package/dist/gexf.js.map +1 -1
  50. package/dist/gml.js +170 -149
  51. package/dist/gml.js.map +1 -1
  52. package/dist/graph-io.js +27 -27
  53. package/dist/graphml.js +2 -2
  54. package/dist/json.js +1 -1
  55. package/dist/neo4j.js +103 -65
  56. package/dist/neo4j.js.map +1 -1
  57. package/dist/obo.js +1 -1
  58. package/dist/pajek.js +1 -1
  59. package/dist/src/children.d.ts.map +1 -1
  60. package/dist/src/children.js +50 -27
  61. package/dist/src/children.js.map +1 -1
  62. package/dist/src/common/attributes.d.ts.map +1 -1
  63. package/dist/src/common/attributes.js +86 -42
  64. package/dist/src/common/attributes.js.map +1 -1
  65. package/dist/src/common/input.d.ts.map +1 -1
  66. package/dist/src/common/input.js +358 -184
  67. package/dist/src/common/input.js.map +1 -1
  68. package/dist/src/common/lists.js +30 -15
  69. package/dist/src/common/lists.js.map +1 -1
  70. package/dist/src/common/text.d.ts +7 -0
  71. package/dist/src/common/text.d.ts.map +1 -1
  72. package/dist/src/common/text.js +15 -7
  73. package/dist/src/common/text.js.map +1 -1
  74. package/dist/src/common/zip.d.ts.map +1 -1
  75. package/dist/src/common/zip.js +36 -19
  76. package/dist/src/common/zip.js.map +1 -1
  77. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  78. package/dist/src/formats/csv/exporter.js +288 -146
  79. package/dist/src/formats/csv/exporter.js.map +1 -1
  80. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  81. package/dist/src/formats/csv/importer.js +129 -80
  82. package/dist/src/formats/csv/importer.js.map +1 -1
  83. package/dist/src/formats/csv/records.d.ts.map +1 -1
  84. package/dist/src/formats/csv/records.js +78 -36
  85. package/dist/src/formats/csv/records.js.map +1 -1
  86. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  87. package/dist/src/formats/cys/tables.js +11 -8
  88. package/dist/src/formats/cys/tables.js.map +1 -1
  89. package/dist/src/formats/dot/tokenizer.d.ts +39 -0
  90. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  91. package/dist/src/formats/dot/tokenizer.js +112 -66
  92. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  93. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  94. package/dist/src/formats/gexf/exporter.js +65 -37
  95. package/dist/src/formats/gexf/exporter.js.map +1 -1
  96. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  97. package/dist/src/formats/gexf/importer.js +138 -50
  98. package/dist/src/formats/gexf/importer.js.map +1 -1
  99. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  100. package/dist/src/formats/gml/exporter.js +51 -35
  101. package/dist/src/formats/gml/exporter.js.map +1 -1
  102. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  103. package/dist/src/formats/gml/importer.js +103 -58
  104. package/dist/src/formats/gml/importer.js.map +1 -1
  105. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  106. package/dist/src/formats/gml/syntax.js +17 -8
  107. package/dist/src/formats/gml/syntax.js.map +1 -1
  108. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  109. package/dist/src/formats/graphml/exporter.js +34 -24
  110. package/dist/src/formats/graphml/exporter.js.map +1 -1
  111. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  112. package/dist/src/formats/graphml/importer.js +17 -13
  113. package/dist/src/formats/graphml/importer.js.map +1 -1
  114. package/dist/src/formats/graphml/tree.js +23 -14
  115. package/dist/src/formats/graphml/tree.js.map +1 -1
  116. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  117. package/dist/src/formats/graphml/yfiles.js +22 -10
  118. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  119. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  120. package/dist/src/formats/json/dialect.js +54 -20
  121. package/dist/src/formats/json/dialect.js.map +1 -1
  122. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  123. package/dist/src/formats/json/exporter.js +38 -21
  124. package/dist/src/formats/json/exporter.js.map +1 -1
  125. package/dist/src/formats/json/importer.d.ts.map +1 -1
  126. package/dist/src/formats/json/importer.js +536 -343
  127. package/dist/src/formats/json/importer.js.map +1 -1
  128. package/dist/src/formats/json/obographs.js +29 -17
  129. package/dist/src/formats/json/obographs.js.map +1 -1
  130. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  131. package/dist/src/formats/neo4j/exporter.js +73 -43
  132. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  133. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  134. package/dist/src/formats/neo4j/header.js +37 -16
  135. package/dist/src/formats/neo4j/header.js.map +1 -1
  136. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  137. package/dist/src/formats/neo4j/importer.js +21 -11
  138. package/dist/src/formats/neo4j/importer.js.map +1 -1
  139. package/dist/src/formats/obo/exporter.d.ts.map +1 -1
  140. package/dist/src/formats/obo/exporter.js +18 -7
  141. package/dist/src/formats/obo/exporter.js.map +1 -1
  142. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  143. package/dist/src/formats/pajek/exporter.js +142 -73
  144. package/dist/src/formats/pajek/exporter.js.map +1 -1
  145. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  146. package/dist/src/formats/pajek/importer.js +26 -15
  147. package/dist/src/formats/pajek/importer.js.map +1 -1
  148. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  149. package/dist/src/formats/pajek/syntax.js +85 -58
  150. package/dist/src/formats/pajek/syntax.js.map +1 -1
  151. package/dist/src/formats/xgmml/emit.d.ts +7 -0
  152. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  153. package/dist/src/formats/xgmml/emit.js +79 -40
  154. package/dist/src/formats/xgmml/emit.js.map +1 -1
  155. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  156. package/dist/src/formats/xgmml/exporter.js +38 -12
  157. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  158. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  159. package/dist/src/formats/xgmml/importer.js +38 -18
  160. package/dist/src/formats/xgmml/importer.js.map +1 -1
  161. package/dist/src/sniff.js +109 -57
  162. package/dist/src/sniff.js.map +1 -1
  163. package/dist/xgmml.js +2 -2
  164. package/package.json +2 -2
  165. package/src/children.ts +54 -27
  166. package/src/common/attributes.ts +118 -49
  167. package/src/common/input.ts +439 -197
  168. package/src/common/lists.ts +29 -15
  169. package/src/common/text.ts +24 -15
  170. package/src/common/zip.ts +47 -18
  171. package/src/formats/csv/exporter.ts +377 -175
  172. package/src/formats/csv/importer.ts +155 -84
  173. package/src/formats/csv/records.ts +90 -35
  174. package/src/formats/cys/tables.ts +11 -10
  175. package/src/formats/dot/tokenizer.ts +118 -68
  176. package/src/formats/gexf/exporter.ts +67 -36
  177. package/src/formats/gexf/importer.ts +173 -63
  178. package/src/formats/gml/exporter.ts +78 -82
  179. package/src/formats/gml/importer.ts +119 -68
  180. package/src/formats/gml/syntax.ts +18 -8
  181. package/src/formats/graphml/exporter.ts +44 -24
  182. package/src/formats/graphml/importer.ts +18 -11
  183. package/src/formats/graphml/tree.ts +22 -12
  184. package/src/formats/graphml/yfiles.ts +23 -10
  185. package/src/formats/json/dialect.ts +61 -22
  186. package/src/formats/json/exporter.ts +40 -21
  187. package/src/formats/json/importer.ts +701 -385
  188. package/src/formats/json/obographs.ts +35 -15
  189. package/src/formats/neo4j/exporter.ts +79 -41
  190. package/src/formats/neo4j/header.ts +50 -16
  191. package/src/formats/neo4j/importer.ts +21 -10
  192. package/src/formats/obo/exporter.ts +34 -17
  193. package/src/formats/pajek/exporter.ts +180 -117
  194. package/src/formats/pajek/importer.ts +27 -15
  195. package/src/formats/pajek/syntax.ts +99 -58
  196. package/src/formats/xgmml/emit.ts +88 -40
  197. package/src/formats/xgmml/exporter.ts +40 -12
  198. package/src/formats/xgmml/importer.ts +45 -23
  199. package/src/sniff.ts +115 -59
  200. package/dist/chunks/children-CL3Cy0ez.js.map +0 -1
  201. package/dist/chunks/export-B-aVCwyG.js.map +0 -1
  202. package/dist/chunks/exporter-BVXnV9-9.js.map +0 -1
  203. package/dist/chunks/exporter-Bcp-l4Bo.js.map +0 -1
  204. package/dist/chunks/importer-AjwOJrn2.js.map +0 -1
  205. package/dist/chunks/importer-BpuS3J8C.js.map +0 -1
  206. package/dist/chunks/importer-CPqnIY8m.js.map +0 -1
  207. package/dist/chunks/importer-DEp_fYjf.js.map +0 -1
  208. package/dist/chunks/importer-DcKB97kW.js.map +0 -1
  209. package/dist/chunks/records-r3SFHz11.js.map +0 -1
  210. package/dist/chunks/text-Bjfa1xYL.js.map +0 -1
@@ -22,7 +22,14 @@
22
22
  * colon is written `id:` when it has no weight so the importer does not read its tail as one.
23
23
  */
24
24
 
25
- import { type Column, GraphFormatError, type GraphSnapshot, type NodeId } from "@graphty/graph-format";
25
+ import {
26
+ type Column,
27
+ type F32Column,
28
+ type F64Column,
29
+ GraphFormatError,
30
+ type GraphSnapshot,
31
+ type NodeId,
32
+ } from "@graphty/graph-format";
26
33
 
27
34
  import { DICT_SAMPLE_ROWS, DictHeuristic } from "../../common/attributes.js";
28
35
  import { type PairFolding, pairFolding } from "../../common/direction.js";
@@ -371,16 +378,6 @@ function cellText(column: Column, row: number): string | null {
371
378
  }
372
379
  }
373
380
 
374
- /**
375
- * Build the plan of an export: resolve options, choose the rows and columns, and collect the
376
- * CSV-specific loss notes (check() adds the generic ones). The value-level notes need a pass over
377
- * every written cell and are collected for check() only; export() needs the plan and the fatal
378
- * id-collision note alone.
379
- * @param snapshot - the snapshot
380
- * @param options - the caller's options
381
- * @param values - whether to scan the values for notes
382
- * @returns the plan
383
- */
384
381
  /**
385
382
  * W_CSV_NODE_TABLE: the node columns an edge-table or adjacency export leaves for a second, node-table export.
386
383
  * @param names - the node columns, label first
@@ -407,6 +404,25 @@ function nodeTableNote(names: readonly string[], table: string, notes: LossNote[
407
404
  }
408
405
  }
409
406
 
407
+ /**
408
+ * The number of node ids whose text an earlier id already has (a number and a string).
409
+ * @param ids - the snapshot's ids
410
+ * @returns the collisions
411
+ */
412
+ function idTextCollisions(ids: GraphSnapshot["ids"]): number {
413
+ const seen = new Set<string>();
414
+ let collisions = 0;
415
+ for (let i = 0; i < ids.size; i++) {
416
+ const text = String(ids.idOf(i));
417
+ if (seen.has(text)) {
418
+ collisions++;
419
+ } else {
420
+ seen.add(text);
421
+ }
422
+ }
423
+ return collisions;
424
+ }
425
+
410
426
  /**
411
427
  * The id notes: text collisions are fatal (export() throws E_INVALID_ID); type changes under the
412
428
  * canonical re-read are reported.
@@ -421,16 +437,7 @@ function idNotes(
421
437
  ): void {
422
438
  const { ids } = snapshot;
423
439
  if (ids.kind === "mixed") {
424
- const seen = new Set<string>();
425
- let collisions = 0;
426
- for (let i = 0; i < ids.size; i++) {
427
- const text = String(ids.idOf(i));
428
- if (seen.has(text)) {
429
- collisions++;
430
- } else {
431
- seen.add(text);
432
- }
433
- }
440
+ const collisions = idTextCollisions(ids);
434
441
  if (collisions > 0) {
435
442
  note(
436
443
  CSV_LOSS.ID_TEXT_COLLISION,
@@ -459,6 +466,16 @@ function idNotes(
459
466
  }
460
467
  }
461
468
 
469
+ /**
470
+ * Build the plan of an export: resolve options, choose the rows and columns, and collect the
471
+ * CSV-specific loss notes (check() adds the generic ones). The value-level notes need a pass over
472
+ * every written cell and are collected for check() only; export() needs the plan and the fatal
473
+ * id-collision note alone.
474
+ * @param snapshot - the snapshot
475
+ * @param options - the caller's options
476
+ * @param values - whether to scan the values for notes
477
+ * @returns the plan
478
+ */
462
479
  function planExport(
463
480
  snapshot: GraphSnapshot,
464
481
  options: (CsvExportOptions & CommonExportOptions) | undefined,
@@ -482,7 +499,66 @@ function planExport(
482
499
  edgeRows.push(e);
483
500
  }
484
501
  }
485
- // A node table holds no edge: the direction and mutual notes are the edge table's.
502
+ directionNotes(snapshot, csv, edgeRows.length, note);
503
+ if (folding.mutualCount > 0 && csv.table !== "nodes") {
504
+ note(
505
+ CSV_LOSS.MUTUAL_EXPANDED,
506
+ `${folding.mutualCount} mutual pair${plural(folding.mutualCount)} ${agree(folding.mutualCount, "is", "are")} written as two directed rows; the mutual mark is lost`,
507
+ null,
508
+ folding.mutualCount,
509
+ );
510
+ }
511
+ if (csv.table === "edges") {
512
+ noteNodeCoverage(snapshot, edgeRows, note);
513
+ }
514
+
515
+ // weights
516
+ const weights = explicitWeights(snapshot);
517
+
518
+ const { edgeId, edgeLabel, edgeColumns } = planEdgeColumns(snapshot, csv, edgeRows, values, note);
519
+ const { nodeLabel, nodeColumns } = planNodeColumns(snapshot, csv, values, note);
520
+ if (csv.table !== "nodes") {
521
+ nodeTableNote(
522
+ [...(nodeLabel === null ? [] : [nodeLabel.meta.name]), ...nodeColumns.map((c) => c.column.meta.name)],
523
+ csv.table,
524
+ notes,
525
+ );
526
+ }
527
+
528
+ return {
529
+ csv,
530
+ common,
531
+ notes,
532
+ edgeRows,
533
+ folding,
534
+ edgeId,
535
+ edgeLabel,
536
+ weights,
537
+ edgeColumns,
538
+ nodeLabel,
539
+ nodeColumns,
540
+ idText,
541
+ };
542
+ }
543
+
544
+ /** Records one loss note. */
545
+ type NoteFn = (code: string, message: string, column?: string | null, count?: number | null) => void;
546
+
547
+ /**
548
+ * W_CSV_DIRECTION_DROPPED: a table with no direction column (the generic dialect, an adjacency
549
+ * table) loses the direction of undirected edges, and the Gephi edge table of an undirected graph
550
+ * without edges has no Type cell to carry it. A node table holds no edge, so it gets no note.
551
+ * @param snapshot - the snapshot
552
+ * @param csv - the resolved CSV options
553
+ * @param edgeRowCount - how many edge rows are written
554
+ * @param note - the recorder
555
+ */
556
+ function directionNotes(
557
+ snapshot: GraphSnapshot,
558
+ csv: ResolvedCsvExportOptions,
559
+ edgeRowCount: number,
560
+ note: NoteFn,
561
+ ): void {
486
562
  if (csv.table !== "nodes" && (csv.dialect.type === null || csv.table === "adjacency")) {
487
563
  const where = csv.table === "adjacency" ? "an adjacency table" : "the generic dialect";
488
564
  const mixed = countMixedEdges(snapshot);
@@ -501,7 +577,7 @@ function planExport(
501
577
  mixed,
502
578
  );
503
579
  }
504
- } else if (csv.table === "edges" && !snapshot.directed && edgeRows.length === 0) {
580
+ } else if (csv.table === "edges" && !snapshot.directed && edgeRowCount === 0) {
505
581
  note(
506
582
  CSV_LOSS.DIRECTION_DROPPED,
507
583
  "the direction lives in the Type cell of each edge row; an undirected graph without edges reads back as directed",
@@ -509,22 +585,25 @@ function planExport(
509
585
  0,
510
586
  );
511
587
  }
512
- if (folding.mutualCount > 0 && csv.table !== "nodes") {
513
- note(
514
- CSV_LOSS.MUTUAL_EXPANDED,
515
- `${folding.mutualCount} mutual pair${plural(folding.mutualCount)} ${agree(folding.mutualCount, "is", "are")} written as two directed rows; the mutual mark is lost`,
516
- null,
517
- folding.mutualCount,
518
- );
519
- }
520
- if (csv.table === "edges") {
521
- noteNodeCoverage(snapshot, edgeRows, note);
522
- }
523
-
524
- // weights
525
- const weights = explicitWeights(snapshot);
588
+ }
526
589
 
527
- // the edge table's columns
590
+ /**
591
+ * The edge table's columns, with their notes: the attribute columns' names (edge table only), the
592
+ * columns an adjacency table drops, and the values of every column the edge table writes.
593
+ * @param snapshot - the snapshot
594
+ * @param csv - the resolved CSV options
595
+ * @param edgeRows - the edge rows written
596
+ * @param values - whether the values are inspected
597
+ * @param note - the recorder
598
+ * @returns the id and label role columns and the attribute columns
599
+ */
600
+ function planEdgeColumns(
601
+ snapshot: GraphSnapshot,
602
+ csv: ResolvedCsvExportOptions,
603
+ edgeRows: readonly number[],
604
+ values: boolean,
605
+ note: NoteFn,
606
+ ): { edgeId: Column | null; edgeLabel: Column | null; edgeColumns: ColumnOut[] } {
528
607
  const edgeId = snapshot.edges.byRole("id");
529
608
  const edgeLabel = snapshot.edges.byRole("label");
530
609
  const edgeReserved = new Set<string>([csv.dialect.source, csv.dialect.target, csv.dialect.weight].map(lower));
@@ -557,8 +636,23 @@ function planExport(
557
636
  checkValues(column, "edge", edgeRows, note);
558
637
  }
559
638
  }
639
+ return { edgeId, edgeLabel, edgeColumns };
640
+ }
560
641
 
561
- // the node table's columns
642
+ /**
643
+ * The node table's columns, with their notes when the node table is the one written.
644
+ * @param snapshot - the snapshot
645
+ * @param csv - the resolved CSV options
646
+ * @param values - whether the values are inspected
647
+ * @param note - the recorder
648
+ * @returns the label role column and the attribute columns
649
+ */
650
+ function planNodeColumns(
651
+ snapshot: GraphSnapshot,
652
+ csv: ResolvedCsvExportOptions,
653
+ values: boolean,
654
+ note: NoteFn,
655
+ ): { nodeLabel: Column | null; nodeColumns: ColumnOut[] } {
562
656
  const nodeLabel = snapshot.nodes.byRole("label");
563
657
  const nodeReserved = new Set<string>([lower(csv.dialect.id)]);
564
658
  const nodeRows: number[] = [];
@@ -575,35 +669,13 @@ function planExport(
575
669
  nodeLabel,
576
670
  csv.table === "nodes" ? note : null,
577
671
  );
578
- if (csv.table === "nodes") {
579
- if (values) {
580
- checkRoleColumn(nodeLabel, "label", LABEL_NAMES, "node", nodeRows, note);
581
- for (const { column } of nodeColumns) {
582
- checkValues(column, "node", nodeRows, note);
583
- }
672
+ if (csv.table === "nodes" && values) {
673
+ checkRoleColumn(nodeLabel, "label", LABEL_NAMES, "node", nodeRows, note);
674
+ for (const { column } of nodeColumns) {
675
+ checkValues(column, "node", nodeRows, note);
584
676
  }
585
- } else {
586
- nodeTableNote(
587
- [...(nodeLabel === null ? [] : [nodeLabel.meta.name]), ...nodeColumns.map((c) => c.column.meta.name)],
588
- csv.table,
589
- notes,
590
- );
591
677
  }
592
-
593
- return {
594
- csv,
595
- common,
596
- notes,
597
- edgeRows,
598
- folding,
599
- edgeId,
600
- edgeLabel,
601
- weights,
602
- edgeColumns,
603
- nodeLabel,
604
- nodeColumns,
605
- idText,
606
- };
678
+ return { nodeLabel, nodeColumns };
607
679
  }
608
680
 
609
681
  /**
@@ -699,40 +771,81 @@ function attributeColumns(
699
771
  if (role !== null && SKIPPED_ROLES.has(role)) {
700
772
  continue;
701
773
  }
702
- const low = lower(name);
703
774
  const set = column.length - column.nullCount;
704
- const isIdName = domain === "edge" ? findColumn([name], EDGE_ID_NAMES) >= 0 : false;
705
- if (reserved.has(low) || isIdName || (idColumn !== null && lower(idColumn.meta.name) === low)) {
706
- note?.(
707
- CSV_LOSS.RESERVED_NAME,
708
- `${domain} column "${name}" is not written: the name is reserved for the ${isIdName ? "edge id" : low} column`,
709
- name,
710
- set,
711
- );
775
+ if (isReservedName(name, domain, reserved, idColumn, set, note)) {
712
776
  continue;
713
777
  }
714
- if (findColumn([name], LABEL_NAMES) >= 0) {
715
- if (labelColumn !== null) {
716
- note?.(
717
- CSV_LOSS.RESERVED_NAME,
718
- `${domain} column "${name}" is not written: the name is reserved for the label column`,
719
- name,
720
- set,
721
- );
722
- continue;
723
- }
724
- note?.(
725
- CSV_LOSS.ROLE_ASSUMED,
726
- `${domain} column "${name}" reads back with the label role (string)`,
727
- name,
728
- set,
729
- );
778
+ if (findColumn([name], LABEL_NAMES) >= 0 && !labelNameWritten(name, domain, labelColumn, set, note)) {
779
+ continue;
730
780
  }
731
781
  out.push({ column, header: name });
732
782
  }
733
783
  return out;
734
784
  }
735
785
 
786
+ /**
787
+ * Whether an attribute column's name is reserved for a structural column (a dialect header, an
788
+ * edge id name, the id column's name), with its note.
789
+ * @param name - the column name
790
+ * @param domain - node or edge, for messages
791
+ * @param reserved - lower-case header names the dialect writes for structure
792
+ * @param idColumn - the table's id role column, or null
793
+ * @param set - the column's set cells
794
+ * @param note - the recorder, or null when this table's notes are not wanted
795
+ * @returns true when the column is not written
796
+ */
797
+ function isReservedName(
798
+ name: string,
799
+ domain: "node" | "edge",
800
+ reserved: ReadonlySet<string>,
801
+ idColumn: Column | null,
802
+ set: number,
803
+ note: ((code: string, message: string, column?: string | null, count?: number | null) => void) | null,
804
+ ): boolean {
805
+ const low = lower(name);
806
+ const isIdName = domain === "edge" ? findColumn([name], EDGE_ID_NAMES) >= 0 : false;
807
+ if (reserved.has(low) || isIdName || (idColumn !== null && lower(idColumn.meta.name) === low)) {
808
+ note?.(
809
+ CSV_LOSS.RESERVED_NAME,
810
+ `${domain} column "${name}" is not written: the name is reserved for the ${isIdName ? "edge id" : low} column`,
811
+ name,
812
+ set,
813
+ );
814
+ return true;
815
+ }
816
+ return false;
817
+ }
818
+
819
+ /**
820
+ * Whether an attribute column with a label header name is written, with its note: not when the
821
+ * table has a label column (the name is reserved for it), else it reads back with the label role.
822
+ * @param name - the column name
823
+ * @param domain - node or edge, for messages
824
+ * @param labelColumn - the table's label role column, or null
825
+ * @param set - the column's set cells
826
+ * @param note - the recorder, or null when this table's notes are not wanted
827
+ * @returns true when the column is written
828
+ */
829
+ function labelNameWritten(
830
+ name: string,
831
+ domain: "node" | "edge",
832
+ labelColumn: Column | null,
833
+ set: number,
834
+ note: ((code: string, message: string, column?: string | null, count?: number | null) => void) | null,
835
+ ): boolean {
836
+ if (labelColumn !== null) {
837
+ note?.(
838
+ CSV_LOSS.RESERVED_NAME,
839
+ `${domain} column "${name}" is not written: the name is reserved for the label column`,
840
+ name,
841
+ set,
842
+ );
843
+ return false;
844
+ }
845
+ note?.(CSV_LOSS.ROLE_ASSUMED, `${domain} column "${name}" reads back with the label role (string)`, name, set);
846
+ return true;
847
+ }
848
+
736
849
  /**
737
850
  * Notes about a role column (id or label): a name the importer does not map to the role, and a
738
851
  * dtype other than string / dict (read back as string).
@@ -808,26 +921,13 @@ function checkValues(
808
921
  column: Column,
809
922
  domain: "node" | "edge",
810
923
  rows: readonly number[],
811
- note: (code: string, message: string, column?: string | null, count?: number | null) => void,
924
+ note: NoteFn,
812
925
  roleColumn = false,
813
926
  ): void {
814
927
  const { name, dtype } = column.meta;
815
928
  const label = `${domain} column "${name}"`;
816
929
  if (column.dtype === "f32" || column.dtype === "f64") {
817
- let nonFinite = 0;
818
- const { components } = column.meta;
819
- const { data } = column;
820
- for (const row of rows) {
821
- if (!column.isSet(row)) {
822
- continue;
823
- }
824
- for (let k = 0; k < components; k++) {
825
- if (!Number.isFinite(data[row * components + k])) {
826
- nonFinite++;
827
- break;
828
- }
829
- }
830
- }
930
+ const nonFinite = countNonFinite(column, rows);
831
931
  if (nonFinite > 0) {
832
932
  note(
833
933
  CSV_LOSS.NONFINITE,
@@ -841,15 +941,77 @@ function checkValues(
841
941
  if (dtype !== "string" && dtype !== "dict") {
842
942
  return;
843
943
  }
944
+ const scan = scanText(column, rows);
945
+ if (roleColumn || scan.widest < 0) {
946
+ return;
947
+ }
948
+ const readsAsDict = scan.candidate && scan.heuristic.decide() === "dict";
949
+ if (scan.widest < WIDENING.string && !readsAsDict) {
950
+ note(CSV_LOSS.TEXT_INFERRED, `${label}: every value reads back as ${TEXT_DTYPES[scan.widest]}`, name, scan.set);
951
+ return;
952
+ }
953
+ if (dtype === "dict" && !readsAsDict) {
954
+ note(
955
+ CSV_LOSS.STORAGE_CLASS_CHANGED,
956
+ `${label}: reads back as string (cardinality too high for a dict)`,
957
+ name,
958
+ null,
959
+ );
960
+ } else if (dtype === "string" && readsAsDict) {
961
+ note(CSV_LOSS.STORAGE_CLASS_CHANGED, `${label}: reads back as dict (low cardinality)`, name, null);
962
+ }
963
+ }
964
+
965
+ /**
966
+ * The rows of a float column holding at least one non-finite component.
967
+ * @param column - the f32 or f64 column
968
+ * @param rows - the rows written
969
+ * @returns how many rows hold one
970
+ */
971
+ function countNonFinite(column: F32Column | F64Column, rows: readonly number[]): number {
972
+ let nonFinite = 0;
973
+ const { components } = column.meta;
974
+ const { data } = column;
975
+ for (const row of rows) {
976
+ if (!column.isSet(row)) {
977
+ continue;
978
+ }
979
+ for (let k = 0; k < components; k++) {
980
+ if (!Number.isFinite(data[row * components + k])) {
981
+ nonFinite++;
982
+ break;
983
+ }
984
+ }
985
+ }
986
+ return nonFinite;
987
+ }
988
+
989
+ /** What a text column's written values read back as. */
990
+ interface TextScan {
991
+ /** The widest dtype the values infer to (WIDENING), -1 when no value is set. */
992
+ readonly widest: number;
993
+ /** Whether every value the dict heuristic sampled infers to string. */
994
+ readonly candidate: boolean;
995
+ /** How many rows hold a string. */
996
+ readonly set: number;
997
+ /** The dict heuristic over the sampled values. */
998
+ readonly heuristic: DictHeuristic;
999
+ }
1000
+
1001
+ /**
1002
+ * Infer the dtype every written value of a text column reads back as, and sample the values for
1003
+ * the dict heuristic.
1004
+ * @param column - the string or dict column
1005
+ * @param rows - the rows written
1006
+ * @returns the scan
1007
+ */
1008
+ function scanText(column: Column, rows: readonly number[]): TextScan {
844
1009
  let widest = -1;
845
1010
  let candidate = true;
846
1011
  let set = 0;
847
1012
  const heuristic = new DictHeuristic(DICT_SAMPLE_ROWS);
848
1013
  for (const row of rows) {
849
- if (!column.isSet(row)) {
850
- continue;
851
- }
852
- const text = column.value(row);
1014
+ const text = column.isSet(row) ? column.value(row) : null;
853
1015
  if (typeof text !== "string") {
854
1016
  continue;
855
1017
  }
@@ -857,30 +1019,11 @@ function checkValues(
857
1019
  const kind = inferTextDtype(text);
858
1020
  widest = Math.max(widest, WIDENING[kind]);
859
1021
  if (!heuristic.decided) {
860
- if (kind !== "string") {
861
- candidate = false;
862
- }
1022
+ candidate &&= kind === "string";
863
1023
  heuristic.observe(text);
864
1024
  }
865
1025
  }
866
- if (roleColumn || widest < 0) {
867
- return;
868
- }
869
- const readsAsDict = candidate && heuristic.decide() === "dict";
870
- if (widest < WIDENING.string && !readsAsDict) {
871
- note(CSV_LOSS.TEXT_INFERRED, `${label}: every value reads back as ${TEXT_DTYPES[widest]}`, name, set);
872
- return;
873
- }
874
- if (dtype === "dict" && !readsAsDict) {
875
- note(
876
- CSV_LOSS.STORAGE_CLASS_CHANGED,
877
- `${label}: reads back as string (cardinality too high for a dict)`,
878
- name,
879
- null,
880
- );
881
- } else if (dtype === "string" && readsAsDict) {
882
- note(CSV_LOSS.STORAGE_CLASS_CHANGED, `${label}: reads back as dict (low cardinality)`, name, null);
883
- }
1026
+ return { widest, candidate, set, heuristic };
884
1027
  }
885
1028
 
886
1029
  /**
@@ -912,6 +1055,22 @@ function neighbourCell(id: string, weight: string | null): string {
912
1055
  return id.includes(":") ? `${id}:` : id;
913
1056
  }
914
1057
 
1058
+ /**
1059
+ * The end of the run of consecutive edge rows that share the source of row `r`.
1060
+ * @param src - the edge sources by edge index
1061
+ * @param edgeRows - the edges in write order
1062
+ * @param r - the run's first row
1063
+ * @returns the index after the run's last row
1064
+ */
1065
+ function sourceRunEnd(src: ArrayLike<number>, edgeRows: ArrayLike<number>, r: number): number {
1066
+ const source = src[edgeRows[r]];
1067
+ let end = r;
1068
+ while (end < edgeRows.length && src[edgeRows[end]] === source) {
1069
+ end++;
1070
+ }
1071
+ return end;
1072
+ }
1073
+
915
1074
  /**
916
1075
  * The rows of the adjacency table (design section 8.5: the order survives the importer's first-
917
1076
  * appearance numbering). Consecutive edges with one source share a row, so the edge order is kept;
@@ -953,10 +1112,7 @@ function* adjacencyLines(snapshot: GraphSnapshot, plan: Plan): Generator<string,
953
1112
  let r = 0;
954
1113
  while (r < edgeRows.length) {
955
1114
  const source = list.src[edgeRows[r]];
956
- let end = r;
957
- while (end < edgeRows.length && list.src[edgeRows[end]] === source) {
958
- end++;
959
- }
1115
+ const end = sourceRunEnd(list.src, edgeRows, r);
960
1116
  // the nodes this row introduces, in the order the importer meets them; in index order from
961
1117
  // `next` they need nothing more
962
1118
  const fresh: number[] = [];
@@ -1000,59 +1156,79 @@ function* adjacencyLines(snapshot: GraphSnapshot, plan: Plan): Generator<string,
1000
1156
  * @returns nothing
1001
1157
  */
1002
1158
  function* lines(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, undefined> {
1003
- const { csv } = plan;
1004
- if (csv.table === "adjacency") {
1159
+ if (plan.csv.table === "adjacency") {
1005
1160
  yield* adjacencyLines(snapshot, plan);
1006
- return;
1161
+ } else if (plan.csv.table === "nodes") {
1162
+ yield* nodeLines(snapshot, plan);
1163
+ } else {
1164
+ yield* edgeLines(snapshot, plan);
1007
1165
  }
1008
- const { delimiter, newline } = csv;
1166
+ }
1167
+
1168
+ /**
1169
+ * The cell writers of a delimiter.
1170
+ * @param delimiter - the field delimiter
1171
+ * @returns `quote` for a text and `cell` for a column value (an unset value is the empty cell)
1172
+ */
1173
+ function cellWriters(delimiter: string): {
1174
+ quote: (text: string) => string;
1175
+ cell: (column: Column, row: number) => string;
1176
+ } {
1009
1177
  const quote = (text: string): string => quoteCsvCell(text, delimiter);
1010
1178
  const cell = (column: Column, row: number): string => {
1011
1179
  const text = cellText(column, row);
1012
1180
  return text === null ? "" : quote(text);
1013
1181
  };
1014
- if (csv.table === "nodes") {
1015
- const headers = [csv.dialect.id];
1016
- if (plan.nodeLabel !== null) {
1017
- headers.push(plan.nodeLabel.meta.name);
1018
- }
1019
- for (const { header } of plan.nodeColumns) {
1020
- headers.push(header);
1021
- }
1022
- if (csv.header) {
1023
- yield headers.map(quote).join(delimiter) + newline;
1024
- }
1025
- for (let i = 0; i < snapshot.nodeCount; i++) {
1026
- const cells = [quote(plan.idText(i))];
1027
- if (plan.nodeLabel !== null) {
1028
- cells.push(cell(plan.nodeLabel, i));
1029
- }
1030
- for (const { column } of plan.nodeColumns) {
1031
- cells.push(cell(column, i));
1032
- }
1033
- yield cells.join(delimiter) + newline;
1034
- }
1035
- return;
1036
- }
1037
- const headers = [csv.dialect.source, csv.dialect.target];
1038
- if (csv.dialect.type !== null) {
1039
- headers.push(csv.dialect.type);
1040
- }
1041
- if (plan.edgeId !== null) {
1042
- headers.push(plan.edgeId.meta.name);
1043
- }
1044
- if (plan.edgeLabel !== null) {
1045
- headers.push(plan.edgeLabel.meta.name);
1046
- }
1047
- if (plan.weights.weighted) {
1048
- headers.push(csv.dialect.weight);
1182
+ return { quote, cell };
1183
+ }
1184
+
1185
+ /**
1186
+ * The lines of the node table: the id, the label and the attribute columns of every node.
1187
+ * @param snapshot - the snapshot
1188
+ * @param plan - the plan
1189
+ * @yields one line at a time
1190
+ * @returns nothing
1191
+ */
1192
+ function* nodeLines(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, undefined> {
1193
+ const { csv } = plan;
1194
+ const { delimiter, newline } = csv;
1195
+ const { quote, cell } = cellWriters(delimiter);
1196
+ const headers = [csv.dialect.id];
1197
+ if (plan.nodeLabel !== null) {
1198
+ headers.push(plan.nodeLabel.meta.name);
1049
1199
  }
1050
- for (const { header } of plan.edgeColumns) {
1200
+ for (const { header } of plan.nodeColumns) {
1051
1201
  headers.push(header);
1052
1202
  }
1053
1203
  if (csv.header) {
1054
1204
  yield headers.map(quote).join(delimiter) + newline;
1055
1205
  }
1206
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1207
+ const cells = [quote(plan.idText(i))];
1208
+ if (plan.nodeLabel !== null) {
1209
+ cells.push(cell(plan.nodeLabel, i));
1210
+ }
1211
+ for (const { column } of plan.nodeColumns) {
1212
+ cells.push(cell(column, i));
1213
+ }
1214
+ yield cells.join(delimiter) + newline;
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * The lines of the edge table: one row per written edge.
1220
+ * @param snapshot - the snapshot
1221
+ * @param plan - the plan
1222
+ * @yields one line at a time
1223
+ * @returns nothing
1224
+ */
1225
+ function* edgeLines(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, undefined> {
1226
+ const { csv } = plan;
1227
+ const { delimiter, newline } = csv;
1228
+ const { quote, cell } = cellWriters(delimiter);
1229
+ if (csv.header) {
1230
+ yield edgeHeaders(plan).map(quote).join(delimiter) + newline;
1231
+ }
1056
1232
  const list = snapshot.edgeList();
1057
1233
  for (const e of plan.edgeRows) {
1058
1234
  const cells = [quote(plan.idText(list.src[e])), quote(plan.idText(list.dst[e]))];
@@ -1075,6 +1251,32 @@ function* lines(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, un
1075
1251
  }
1076
1252
  }
1077
1253
 
1254
+ /**
1255
+ * The header of the edge table.
1256
+ * @param plan - the plan
1257
+ * @returns the header names, unquoted
1258
+ */
1259
+ function edgeHeaders(plan: Plan): string[] {
1260
+ const { dialect } = plan.csv;
1261
+ const headers = [dialect.source, dialect.target];
1262
+ if (dialect.type !== null) {
1263
+ headers.push(dialect.type);
1264
+ }
1265
+ if (plan.edgeId !== null) {
1266
+ headers.push(plan.edgeId.meta.name);
1267
+ }
1268
+ if (plan.edgeLabel !== null) {
1269
+ headers.push(plan.edgeLabel.meta.name);
1270
+ }
1271
+ if (plan.weights.weighted) {
1272
+ headers.push(dialect.weight);
1273
+ }
1274
+ for (const { header } of plan.edgeColumns) {
1275
+ headers.push(header);
1276
+ }
1277
+ return headers;
1278
+ }
1279
+
1078
1280
  /**
1079
1281
  * Plan an export and refuse it when a fatal note is present (E_INVALID_ID for id text collisions),
1080
1282
  * before anything is written.