@graphty/graph-io 0.0.0 → 0.2.1

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 (339) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -28
  3. package/dist/chunks/children-CL3Cy0ez.js +238 -0
  4. package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
  5. package/dist/chunks/escape-DyI8JofU.js +938 -0
  6. package/dist/chunks/escape-DyI8JofU.js.map +1 -0
  7. package/dist/chunks/importer-CQnJuWJw.js +2987 -0
  8. package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
  9. package/dist/chunks/importer-CpCpfbxr.js +2015 -0
  10. package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
  11. package/dist/chunks/importer-DbnGYr3_.js +2342 -0
  12. package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
  13. package/dist/chunks/importer-GozH8DkN.js +3050 -0
  14. package/dist/chunks/importer-GozH8DkN.js.map +1 -0
  15. package/dist/chunks/records-CGpxszm1.js +605 -0
  16. package/dist/chunks/records-CGpxszm1.js.map +1 -0
  17. package/dist/chunks/text-CajMdVFy.js +189 -0
  18. package/dist/chunks/text-CajMdVFy.js.map +1 -0
  19. package/dist/chunks/writer-DxSKC7TL.js +2842 -0
  20. package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
  21. package/dist/csv.d.ts +1 -0
  22. package/dist/csv.js +1702 -0
  23. package/dist/csv.js.map +1 -0
  24. package/dist/dot.d.ts +1 -0
  25. package/dist/dot.js +8 -0
  26. package/dist/dot.js.map +1 -0
  27. package/dist/gexf.d.ts +1 -0
  28. package/dist/gexf.js +3466 -0
  29. package/dist/gexf.js.map +1 -0
  30. package/dist/gml.d.ts +1 -0
  31. package/dist/gml.js +2647 -0
  32. package/dist/gml.js.map +1 -0
  33. package/dist/graph-io.d.ts +1 -0
  34. package/dist/graph-io.js +790 -0
  35. package/dist/graph-io.js.map +1 -0
  36. package/dist/graphml.d.ts +1 -0
  37. package/dist/graphml.js +8 -0
  38. package/dist/graphml.js.map +1 -0
  39. package/dist/json.d.ts +1 -0
  40. package/dist/json.js +11 -0
  41. package/dist/json.js.map +1 -0
  42. package/dist/neo4j.d.ts +1 -0
  43. package/dist/neo4j.js +2046 -0
  44. package/dist/neo4j.js.map +1 -0
  45. package/dist/pajek.d.ts +1 -0
  46. package/dist/pajek.js +8 -0
  47. package/dist/pajek.js.map +1 -0
  48. package/dist/src/children.d.ts +134 -0
  49. package/dist/src/children.d.ts.map +1 -0
  50. package/dist/src/children.js +274 -0
  51. package/dist/src/children.js.map +1 -0
  52. package/dist/src/common/attributes.d.ts +229 -0
  53. package/dist/src/common/attributes.d.ts.map +1 -0
  54. package/dist/src/common/attributes.js +368 -0
  55. package/dist/src/common/attributes.js.map +1 -0
  56. package/dist/src/common/codes.d.ts +105 -0
  57. package/dist/src/common/codes.d.ts.map +1 -0
  58. package/dist/src/common/codes.js +107 -0
  59. package/dist/src/common/codes.js.map +1 -0
  60. package/dist/src/common/declared-types.d.ts +84 -0
  61. package/dist/src/common/declared-types.d.ts.map +1 -0
  62. package/dist/src/common/declared-types.js +326 -0
  63. package/dist/src/common/declared-types.js.map +1 -0
  64. package/dist/src/common/direction.d.ts +206 -0
  65. package/dist/src/common/direction.d.ts.map +1 -0
  66. package/dist/src/common/direction.js +370 -0
  67. package/dist/src/common/direction.js.map +1 -0
  68. package/dist/src/common/escape.d.ts +92 -0
  69. package/dist/src/common/escape.d.ts.map +1 -0
  70. package/dist/src/common/escape.js +212 -0
  71. package/dist/src/common/escape.js.map +1 -0
  72. package/dist/src/common/export.d.ts +249 -0
  73. package/dist/src/common/export.d.ts.map +1 -0
  74. package/dist/src/common/export.js +594 -0
  75. package/dist/src/common/export.js.map +1 -0
  76. package/dist/src/common/format.d.ts +59 -0
  77. package/dist/src/common/format.d.ts.map +1 -0
  78. package/dist/src/common/format.js +106 -0
  79. package/dist/src/common/format.js.map +1 -0
  80. package/dist/src/common/ids.d.ts +83 -0
  81. package/dist/src/common/ids.d.ts.map +1 -0
  82. package/dist/src/common/ids.js +158 -0
  83. package/dist/src/common/ids.js.map +1 -0
  84. package/dist/src/common/input.d.ts +100 -0
  85. package/dist/src/common/input.d.ts.map +1 -0
  86. package/dist/src/common/input.js +335 -0
  87. package/dist/src/common/input.js.map +1 -0
  88. package/dist/src/common/lists.d.ts +34 -0
  89. package/dist/src/common/lists.d.ts.map +1 -0
  90. package/dist/src/common/lists.js +185 -0
  91. package/dist/src/common/lists.js.map +1 -0
  92. package/dist/src/common/options.d.ts +108 -0
  93. package/dist/src/common/options.d.ts.map +1 -0
  94. package/dist/src/common/options.js +265 -0
  95. package/dist/src/common/options.js.map +1 -0
  96. package/dist/src/common/report.d.ts +187 -0
  97. package/dist/src/common/report.d.ts.map +1 -0
  98. package/dist/src/common/report.js +274 -0
  99. package/dist/src/common/report.js.map +1 -0
  100. package/dist/src/common/temporal.d.ts +71 -0
  101. package/dist/src/common/temporal.d.ts.map +1 -0
  102. package/dist/src/common/temporal.js +266 -0
  103. package/dist/src/common/temporal.js.map +1 -0
  104. package/dist/src/common/text.d.ts +104 -0
  105. package/dist/src/common/text.d.ts.map +1 -0
  106. package/dist/src/common/text.js +255 -0
  107. package/dist/src/common/text.js.map +1 -0
  108. package/dist/src/common/weights.d.ts +77 -0
  109. package/dist/src/common/weights.d.ts.map +1 -0
  110. package/dist/src/common/weights.js +156 -0
  111. package/dist/src/common/weights.js.map +1 -0
  112. package/dist/src/common/writer.d.ts +51 -0
  113. package/dist/src/common/writer.d.ts.map +1 -0
  114. package/dist/src/common/writer.js +108 -0
  115. package/dist/src/common/writer.js.map +1 -0
  116. package/dist/src/common/xml.d.ts +245 -0
  117. package/dist/src/common/xml.d.ts.map +1 -0
  118. package/dist/src/common/xml.js +942 -0
  119. package/dist/src/common/xml.js.map +1 -0
  120. package/dist/src/formats/csv/exporter.d.ts +70 -0
  121. package/dist/src/formats/csv/exporter.d.ts.map +1 -0
  122. package/dist/src/formats/csv/exporter.js +682 -0
  123. package/dist/src/formats/csv/exporter.js.map +1 -0
  124. package/dist/src/formats/csv/header.d.ts +66 -0
  125. package/dist/src/formats/csv/header.d.ts.map +1 -0
  126. package/dist/src/formats/csv/header.js +152 -0
  127. package/dist/src/formats/csv/header.js.map +1 -0
  128. package/dist/src/formats/csv/importer.d.ts +82 -0
  129. package/dist/src/formats/csv/importer.d.ts.map +1 -0
  130. package/dist/src/formats/csv/importer.js +849 -0
  131. package/dist/src/formats/csv/importer.js.map +1 -0
  132. package/dist/src/formats/csv/index.d.ts +60 -0
  133. package/dist/src/formats/csv/index.d.ts.map +1 -0
  134. package/dist/src/formats/csv/index.js +63 -0
  135. package/dist/src/formats/csv/index.js.map +1 -0
  136. package/dist/src/formats/csv/records.d.ts +188 -0
  137. package/dist/src/formats/csv/records.d.ts.map +1 -0
  138. package/dist/src/formats/csv/records.js +702 -0
  139. package/dist/src/formats/csv/records.js.map +1 -0
  140. package/dist/src/formats/csv/values.d.ts +105 -0
  141. package/dist/src/formats/csv/values.d.ts.map +1 -0
  142. package/dist/src/formats/csv/values.js +192 -0
  143. package/dist/src/formats/csv/values.js.map +1 -0
  144. package/dist/src/formats/dot/exporter.d.ts +52 -0
  145. package/dist/src/formats/dot/exporter.d.ts.map +1 -0
  146. package/dist/src/formats/dot/exporter.js +836 -0
  147. package/dist/src/formats/dot/exporter.js.map +1 -0
  148. package/dist/src/formats/dot/importer.d.ts +102 -0
  149. package/dist/src/formats/dot/importer.d.ts.map +1 -0
  150. package/dist/src/formats/dot/importer.js +1291 -0
  151. package/dist/src/formats/dot/importer.js.map +1 -0
  152. package/dist/src/formats/dot/index.d.ts +7 -0
  153. package/dist/src/formats/dot/index.d.ts.map +1 -0
  154. package/dist/src/formats/dot/index.js +7 -0
  155. package/dist/src/formats/dot/index.js.map +1 -0
  156. package/dist/src/formats/dot/names.d.ts +29 -0
  157. package/dist/src/formats/dot/names.d.ts.map +1 -0
  158. package/dist/src/formats/dot/names.js +28 -0
  159. package/dist/src/formats/dot/names.js.map +1 -0
  160. package/dist/src/formats/dot/tokenizer.d.ts +114 -0
  161. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
  162. package/dist/src/formats/dot/tokenizer.js +341 -0
  163. package/dist/src/formats/dot/tokenizer.js.map +1 -0
  164. package/dist/src/formats/gexf/exporter.d.ts +56 -0
  165. package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
  166. package/dist/src/formats/gexf/exporter.js +1395 -0
  167. package/dist/src/formats/gexf/exporter.js.map +1 -0
  168. package/dist/src/formats/gexf/importer.d.ts +73 -0
  169. package/dist/src/formats/gexf/importer.d.ts.map +1 -0
  170. package/dist/src/formats/gexf/importer.js +1880 -0
  171. package/dist/src/formats/gexf/importer.js.map +1 -0
  172. package/dist/src/formats/gexf/index.d.ts +96 -0
  173. package/dist/src/formats/gexf/index.d.ts.map +1 -0
  174. package/dist/src/formats/gexf/index.js +97 -0
  175. package/dist/src/formats/gexf/index.js.map +1 -0
  176. package/dist/src/formats/gexf/schema.d.ts +135 -0
  177. package/dist/src/formats/gexf/schema.d.ts.map +1 -0
  178. package/dist/src/formats/gexf/schema.js +323 -0
  179. package/dist/src/formats/gexf/schema.js.map +1 -0
  180. package/dist/src/formats/gml/exporter.d.ts +69 -0
  181. package/dist/src/formats/gml/exporter.d.ts.map +1 -0
  182. package/dist/src/formats/gml/exporter.js +1093 -0
  183. package/dist/src/formats/gml/exporter.js.map +1 -0
  184. package/dist/src/formats/gml/importer.d.ts +66 -0
  185. package/dist/src/formats/gml/importer.d.ts.map +1 -0
  186. package/dist/src/formats/gml/importer.js +1331 -0
  187. package/dist/src/formats/gml/importer.js.map +1 -0
  188. package/dist/src/formats/gml/index.d.ts +85 -0
  189. package/dist/src/formats/gml/index.d.ts.map +1 -0
  190. package/dist/src/formats/gml/index.js +88 -0
  191. package/dist/src/formats/gml/index.js.map +1 -0
  192. package/dist/src/formats/gml/syntax.d.ts +186 -0
  193. package/dist/src/formats/gml/syntax.d.ts.map +1 -0
  194. package/dist/src/formats/gml/syntax.js +467 -0
  195. package/dist/src/formats/gml/syntax.js.map +1 -0
  196. package/dist/src/formats/graphml/constants.d.ts +169 -0
  197. package/dist/src/formats/graphml/constants.d.ts.map +1 -0
  198. package/dist/src/formats/graphml/constants.js +165 -0
  199. package/dist/src/formats/graphml/constants.js.map +1 -0
  200. package/dist/src/formats/graphml/exporter.d.ts +34 -0
  201. package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
  202. package/dist/src/formats/graphml/exporter.js +1176 -0
  203. package/dist/src/formats/graphml/exporter.js.map +1 -0
  204. package/dist/src/formats/graphml/importer.d.ts +31 -0
  205. package/dist/src/formats/graphml/importer.d.ts.map +1 -0
  206. package/dist/src/formats/graphml/importer.js +1607 -0
  207. package/dist/src/formats/graphml/importer.js.map +1 -0
  208. package/dist/src/formats/graphml/index.d.ts +8 -0
  209. package/dist/src/formats/graphml/index.d.ts.map +1 -0
  210. package/dist/src/formats/graphml/index.js +8 -0
  211. package/dist/src/formats/graphml/index.js.map +1 -0
  212. package/dist/src/formats/graphml/tree.d.ts +72 -0
  213. package/dist/src/formats/graphml/tree.d.ts.map +1 -0
  214. package/dist/src/formats/graphml/tree.js +290 -0
  215. package/dist/src/formats/graphml/tree.js.map +1 -0
  216. package/dist/src/formats/json/dialect.d.ts +125 -0
  217. package/dist/src/formats/json/dialect.d.ts.map +1 -0
  218. package/dist/src/formats/json/dialect.js +262 -0
  219. package/dist/src/formats/json/dialect.js.map +1 -0
  220. package/dist/src/formats/json/exporter.d.ts +89 -0
  221. package/dist/src/formats/json/exporter.d.ts.map +1 -0
  222. package/dist/src/formats/json/exporter.js +1358 -0
  223. package/dist/src/formats/json/exporter.js.map +1 -0
  224. package/dist/src/formats/json/importer.d.ts +108 -0
  225. package/dist/src/formats/json/importer.d.ts.map +1 -0
  226. package/dist/src/formats/json/importer.js +1838 -0
  227. package/dist/src/formats/json/importer.js.map +1 -0
  228. package/dist/src/formats/json/index.d.ts +8 -0
  229. package/dist/src/formats/json/index.d.ts.map +1 -0
  230. package/dist/src/formats/json/index.js +8 -0
  231. package/dist/src/formats/json/index.js.map +1 -0
  232. package/dist/src/formats/neo4j/exporter.d.ts +68 -0
  233. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
  234. package/dist/src/formats/neo4j/exporter.js +1055 -0
  235. package/dist/src/formats/neo4j/exporter.js.map +1 -0
  236. package/dist/src/formats/neo4j/header.d.ts +52 -0
  237. package/dist/src/formats/neo4j/header.d.ts.map +1 -0
  238. package/dist/src/formats/neo4j/header.js +131 -0
  239. package/dist/src/formats/neo4j/header.js.map +1 -0
  240. package/dist/src/formats/neo4j/importer.d.ts +73 -0
  241. package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
  242. package/dist/src/formats/neo4j/importer.js +932 -0
  243. package/dist/src/formats/neo4j/importer.js.map +1 -0
  244. package/dist/src/formats/neo4j/index.d.ts +79 -0
  245. package/dist/src/formats/neo4j/index.d.ts.map +1 -0
  246. package/dist/src/formats/neo4j/index.js +83 -0
  247. package/dist/src/formats/neo4j/index.js.map +1 -0
  248. package/dist/src/formats/pajek/exporter.d.ts +58 -0
  249. package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
  250. package/dist/src/formats/pajek/exporter.js +825 -0
  251. package/dist/src/formats/pajek/exporter.js.map +1 -0
  252. package/dist/src/formats/pajek/importer.d.ts +88 -0
  253. package/dist/src/formats/pajek/importer.d.ts.map +1 -0
  254. package/dist/src/formats/pajek/importer.js +1047 -0
  255. package/dist/src/formats/pajek/importer.js.map +1 -0
  256. package/dist/src/formats/pajek/index.d.ts +7 -0
  257. package/dist/src/formats/pajek/index.d.ts.map +1 -0
  258. package/dist/src/formats/pajek/index.js +7 -0
  259. package/dist/src/formats/pajek/index.js.map +1 -0
  260. package/dist/src/formats/pajek/syntax.d.ts +112 -0
  261. package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
  262. package/dist/src/formats/pajek/syntax.js +269 -0
  263. package/dist/src/formats/pajek/syntax.js.map +1 -0
  264. package/dist/src/index.d.ts +35 -0
  265. package/dist/src/index.d.ts.map +1 -0
  266. package/dist/src/index.js +39 -0
  267. package/dist/src/index.js.map +1 -0
  268. package/dist/src/registry.d.ts +207 -0
  269. package/dist/src/registry.d.ts.map +1 -0
  270. package/dist/src/registry.js +481 -0
  271. package/dist/src/registry.js.map +1 -0
  272. package/dist/src/sniff.d.ts +104 -0
  273. package/dist/src/sniff.d.ts.map +1 -0
  274. package/dist/src/sniff.js +357 -0
  275. package/dist/src/sniff.js.map +1 -0
  276. package/dist/src/types.d.ts +238 -0
  277. package/dist/src/types.d.ts.map +1 -0
  278. package/dist/src/types.js +29 -0
  279. package/dist/src/types.js.map +1 -0
  280. package/dist/tsconfig.build.tsbuildinfo +1 -0
  281. package/package.json +122 -7
  282. package/src/children.ts +335 -0
  283. package/src/common/attributes.ts +520 -0
  284. package/src/common/codes.ts +153 -0
  285. package/src/common/declared-types.ts +374 -0
  286. package/src/common/direction.ts +518 -0
  287. package/src/common/escape.ts +231 -0
  288. package/src/common/export.ts +817 -0
  289. package/src/common/format.ts +111 -0
  290. package/src/common/ids.ts +176 -0
  291. package/src/common/input.ts +378 -0
  292. package/src/common/lists.ts +196 -0
  293. package/src/common/options.ts +377 -0
  294. package/src/common/report.ts +352 -0
  295. package/src/common/temporal.ts +302 -0
  296. package/src/common/text.ts +294 -0
  297. package/src/common/weights.ts +202 -0
  298. package/src/common/writer.ts +123 -0
  299. package/src/common/xml.ts +1053 -0
  300. package/src/formats/csv/exporter.ts +894 -0
  301. package/src/formats/csv/header.ts +172 -0
  302. package/src/formats/csv/importer.ts +1104 -0
  303. package/src/formats/csv/index.ts +88 -0
  304. package/src/formats/csv/records.ts +813 -0
  305. package/src/formats/csv/values.ts +224 -0
  306. package/src/formats/dot/exporter.ts +1014 -0
  307. package/src/formats/dot/importer.ts +1549 -0
  308. package/src/formats/dot/index.ts +7 -0
  309. package/src/formats/dot/names.ts +40 -0
  310. package/src/formats/dot/tokenizer.ts +384 -0
  311. package/src/formats/gexf/exporter.ts +1696 -0
  312. package/src/formats/gexf/importer.ts +2333 -0
  313. package/src/formats/gexf/index.ts +142 -0
  314. package/src/formats/gexf/schema.ts +361 -0
  315. package/src/formats/gml/exporter.ts +1404 -0
  316. package/src/formats/gml/importer.ts +1591 -0
  317. package/src/formats/gml/index.ts +128 -0
  318. package/src/formats/gml/syntax.ts +545 -0
  319. package/src/formats/graphml/constants.ts +225 -0
  320. package/src/formats/graphml/exporter.ts +1458 -0
  321. package/src/formats/graphml/importer.ts +2027 -0
  322. package/src/formats/graphml/index.ts +8 -0
  323. package/src/formats/graphml/tree.ts +318 -0
  324. package/src/formats/json/dialect.ts +317 -0
  325. package/src/formats/json/exporter.ts +1616 -0
  326. package/src/formats/json/importer.ts +2271 -0
  327. package/src/formats/json/index.ts +8 -0
  328. package/src/formats/neo4j/exporter.ts +1287 -0
  329. package/src/formats/neo4j/header.ts +156 -0
  330. package/src/formats/neo4j/importer.ts +1220 -0
  331. package/src/formats/neo4j/index.ts +116 -0
  332. package/src/formats/pajek/exporter.ts +1000 -0
  333. package/src/formats/pajek/importer.ts +1311 -0
  334. package/src/formats/pajek/index.ts +7 -0
  335. package/src/formats/pajek/syntax.ts +307 -0
  336. package/src/index.ts +244 -0
  337. package/src/registry.ts +617 -0
  338. package/src/sniff.ts +397 -0
  339. package/src/types.ts +262 -0
@@ -0,0 +1,1220 @@
1
+ /**
2
+ * The Neo4j importer (design sections 8.4 and 5.1, research note 07 sections 2.6 and 2.10): reads
3
+ * neo4j-admin import CSV -- node tables with `:ID`, `:LABEL` and typed property columns,
4
+ * relationship tables with `:START_ID`, `:END_ID`, `:TYPE` and typed property columns -- and pushes
5
+ * scalars into the sink one record at a time. Input is one or more files: the primary input plus the
6
+ * `nodes` and `relationships` option inputs, each read in order; every input holds one or more
7
+ * sections, each starting with its own header row (the single-file convention of graphty-element's
8
+ * CSVDataSource, where a node table and a relationship table follow each other in one file).
9
+ *
10
+ * Mapping (design section 5.1 and decision Q27):
11
+ * - Property columns are declared up front from the header types (`int` -> i32, `long` -> f64
12
+ * with a precision issue beyond 2^53, `float` -> f32, `double` -> f64, `boolean` -> bool,
13
+ * `string` / `char` / `duration` -> string, temporal types -> f64 milliseconds with a `.text`
14
+ * companion when the source text is not canonical, `point` -> json, `type[]` -> list); an
15
+ * untyped column is a string property. An unquoted empty cell is "property not set"; a quoted
16
+ * empty cell is an empty string (or an empty list), as neo4j-admin stores it by default.
17
+ * - `:LABEL` becomes the node list-of-dict column `labels` (role `labels`); `:TYPE` the edge dict
18
+ * column `type` (role `kind`); an id space `:ID(Space)` the node dict column `idSpace` (role
19
+ * `idSpace`), and a stored id `name:ID(Space)` also a string node column `name` whose
20
+ * `origin.namespace` is the space. A property whose name collides with one of those is renamed
21
+ * `<name>#<name>` (design section 5.6).
22
+ * - Ids are text cells coerced by the `ids` option ("canonical" by default, so `1` is the number 1
23
+ * and `007` stays a string; integers beyond 2^53 stay strings). Relationships are always directed
24
+ * ("In Neo4j, all relationships have a direction"), so the sink is set directed before the first
25
+ * edge and `onMixedDirection: "undirected"` is the way to read a file as undirected.
26
+ * - `:IGNORE` columns are skipped and counted in a loss note.
27
+ */
28
+
29
+ import {
30
+ type ColumnDecl,
31
+ type ColumnHandle,
32
+ GraphFormatError,
33
+ type GraphSink,
34
+ INVALID_INDEX,
35
+ type NodeId,
36
+ } from "@graphty/graph-format";
37
+
38
+ import {
39
+ type AttributeDeclarationInput,
40
+ declareAttribute,
41
+ declareCompanion,
42
+ type DeclaredAttribute,
43
+ declareOn,
44
+ losesPrecision,
45
+ parseDeclaredTemporal,
46
+ parseDeclaredValue,
47
+ PRECISION_CODE,
48
+ RENAMED_CODE,
49
+ takenIn,
50
+ uniqueColumnName,
51
+ } from "../../common/attributes.js";
52
+ import {
53
+ DUPLICATE_NODE_CODE as SHARED_DUPLICATE_NODE_CODE,
54
+ MISSING_ENDPOINT_CODE as SHARED_MISSING_ENDPOINT_CODE,
55
+ MISSING_ID_CODE as SHARED_MISSING_ID_CODE,
56
+ ROLE_TAKEN_CODE as SHARED_ROLE_TAKEN_CODE,
57
+ } from "../../common/codes.js";
58
+ import { type DeclaredTypeSpec } from "../../common/declared-types.js";
59
+ import { DirectionResolver } from "../../common/direction.js";
60
+ import { ID_MERGED_CODE as SHARED_ID_MERGED_CODE, IdCoercer } from "../../common/ids.js";
61
+ import { inputLength, isImportInput, type ReadOptions, throwIfAborted } from "../../common/input.js";
62
+ import { type ListSyntax, splitListText } from "../../common/lists.js";
63
+ import {
64
+ reportSinkOptions,
65
+ reportUnusedOptions,
66
+ type ResolvedImportOptions,
67
+ resolveImportOptions,
68
+ } from "../../common/options.js";
69
+ import { ImportReportBuilder } from "../../common/report.js";
70
+ import { parseWeightText } from "../../common/weights.js";
71
+ import {
72
+ type CommonImportOptions,
73
+ type GraphImporter,
74
+ ImportError,
75
+ type ImportInput,
76
+ type ImportReport,
77
+ } from "../../types.js";
78
+ import { checkRecordSyntax, RecordReader, type RecordSyntax } from "../csv/records.js";
79
+ import { type FieldKind, type HeaderField, isHeaderRecord, parseHeaderField } from "./header.js";
80
+
81
+ /** The format-specific options of the Neo4j importer. */
82
+ export interface Neo4jImportOptions {
83
+ /** Further node files, each with its own header row(s); read after the primary input. */
84
+ nodes?: ImportInput | readonly ImportInput[] | undefined;
85
+ /** Relationship files, each with its own header row(s); read after the node files. */
86
+ relationships?: ImportInput | readonly ImportInput[] | undefined;
87
+ /**
88
+ * The field delimiter (neo4j-admin `--delimiter`); one character. When absent it is sniffed
89
+ * from the first rows between "," and a tab, so a `.tsv` file needs no option.
90
+ */
91
+ delimiter?: string | undefined;
92
+ /** The array delimiter of list values and `:LABEL` cells (neo4j-admin `--array-delimiter`); ";" by default. */
93
+ arrayDelimiter?: ";" | "," | "|" | undefined;
94
+ /** The quote character (neo4j-admin `--quote`); one character; a double quote by default. */
95
+ quote?: string | undefined;
96
+ }
97
+
98
+ /** The name of the node list column holding `:LABEL` values. */
99
+ export const LABELS_COLUMN = "labels";
100
+
101
+ /** The name of the edge dict column holding `:TYPE` values. */
102
+ export const TYPE_COLUMN = "type";
103
+
104
+ /** The name of the node dict column holding the id space of `:ID(Space)`. */
105
+ export const ID_SPACE_COLUMN = "idSpace";
106
+
107
+ /** Issue code: a header row (or a whole section) is malformed; the import aborts. */
108
+ export const HEADER_CODE = "E_NEO4J_HEADER";
109
+
110
+ /** Issue code: a row has a different number of cells than its header. */
111
+ export const COLUMN_COUNT_CODE = "E_NEO4J_COLUMN_COUNT";
112
+
113
+ /** Issue code: a node row has an unquoted empty `:ID` cell (a quoted empty cell is the id ""). */
114
+ export const MISSING_ID_CODE = SHARED_MISSING_ID_CODE;
115
+
116
+ /** Issue code: a relationship row has an unquoted empty `:START_ID` or `:END_ID` cell. */
117
+ export const MISSING_ENDPOINT_CODE = SHARED_MISSING_ENDPOINT_CODE;
118
+
119
+ /** Issue code: a node id was declared twice (same id space); the later row's properties win. */
120
+ export const DUPLICATE_NODE_CODE = SHARED_DUPLICATE_NODE_CODE;
121
+
122
+ /** Issue code: a node id was declared in two id spaces; the core has one id space and the later row is skipped. */
123
+ export const ID_SPACE_COLLISION_CODE = "E_NEO4J_ID_SPACE_COLLISION";
124
+
125
+ /** Issue code: two different id cells became one id under `ids: "number"`. */
126
+ export const ID_MERGED_CODE = SHARED_ID_MERGED_CODE;
127
+
128
+ /** Issue code: a header brace option the importer does not act on. */
129
+ export const HEADER_OPTION_CODE = "W_NEO4J_HEADER_OPTION_IGNORED";
130
+
131
+ /** Issue code: a reserved column (labels / type / idSpace) lost its role because the sink already holds it. */
132
+ export const ROLE_TAKEN_CODE = SHARED_ROLE_TAKEN_CODE;
133
+
134
+ /** Loss code: `:IGNORE` columns were skipped. */
135
+ export const IGNORED_COLUMNS_LOSS = "W_NEO4J_IGNORED_COLUMNS";
136
+
137
+ /** The common options the Neo4j importer reads (the rest is reported by reportUnusedOptions). */
138
+ const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
139
+ "ids",
140
+ "addMissingNodes",
141
+ "duplicateEdges",
142
+ "selfLoops",
143
+ "onMixedDirection",
144
+ "weightFrom",
145
+ "weightDtype",
146
+ "long",
147
+ "errorLimit",
148
+ "signal",
149
+ "onProgress",
150
+ ]);
151
+
152
+ const NEO4J = "neo4j";
153
+
154
+ /** Rows between two checks of the cancellation signal (a whole string input is one chunk). */
155
+ const ABORT_CHECK_INTERVAL = 64;
156
+
157
+ const LABELS_DECL: ColumnDecl = {
158
+ name: LABELS_COLUMN,
159
+ dtype: "list",
160
+ itemDtype: "dict",
161
+ nullable: true,
162
+ role: "labels",
163
+ origin: { format: NEO4J, id: ":LABEL", title: null, type: "LABEL", namespace: null },
164
+ };
165
+
166
+ const TYPE_DECL: ColumnDecl = {
167
+ name: TYPE_COLUMN,
168
+ dtype: "dict",
169
+ nullable: true,
170
+ role: "kind",
171
+ origin: { format: NEO4J, id: ":TYPE", title: null, type: "TYPE", namespace: null },
172
+ };
173
+
174
+ const ID_SPACE_DECL: ColumnDecl = {
175
+ name: ID_SPACE_COLUMN,
176
+ dtype: "dict",
177
+ nullable: true,
178
+ role: "idSpace",
179
+ origin: { format: NEO4J, id: ":ID", title: null, type: "ID", namespace: null },
180
+ };
181
+
182
+ const ARRAY_DELIMITERS: Readonly<Record<string, ListSyntax>> = { ";": "semicolon", ",": "comma", "|": "pipe" };
183
+
184
+ /** The resolved format-specific options. */
185
+ interface ResolvedNeo4jOptions {
186
+ readonly nodes: readonly ImportInput[];
187
+ readonly relationships: readonly ImportInput[];
188
+ readonly syntax: RecordSyntax;
189
+ readonly listSyntax: ListSyntax;
190
+ }
191
+
192
+ /** One property column of a section. */
193
+ interface PropertySlot {
194
+ /** The cell index. */
195
+ readonly cell: number;
196
+ /** The column name (after any rename). */
197
+ readonly name: string;
198
+ /** The column handle. */
199
+ readonly handle: ColumnHandle;
200
+ /** How the cell text is parsed. */
201
+ readonly spec: DeclaredTypeSpec;
202
+ /** The declaration of the companion text column of a temporal property, or null. */
203
+ readonly companionDecl: ColumnDecl | null;
204
+ /** The companion's handle once a value needed it (design section 5.1); INVALID_INDEX before. */
205
+ companion: ColumnHandle;
206
+ }
207
+
208
+ /** A node section: the header interpreted. */
209
+ interface NodeSection {
210
+ readonly kind: "node";
211
+ readonly width: number;
212
+ readonly idCell: number;
213
+ /** The column holding the id as a property (`name:ID`), or INVALID_INDEX. */
214
+ readonly idHandle: ColumnHandle;
215
+ readonly space: string | null;
216
+ readonly spaceCode: number;
217
+ readonly labelCells: readonly number[];
218
+ readonly extraLabels: readonly string[];
219
+ readonly properties: readonly PropertySlot[];
220
+ }
221
+
222
+ /** A relationship section: the header interpreted. */
223
+ interface RelationshipSection {
224
+ readonly kind: "relationship";
225
+ readonly width: number;
226
+ readonly startCell: number;
227
+ readonly endCell: number;
228
+ /** The `:TYPE` cell, or -1. */
229
+ readonly typeCell: number;
230
+ /** The cell of the property named by `weightFrom`, or -1. */
231
+ readonly weightCell: number;
232
+ readonly properties: readonly PropertySlot[];
233
+ }
234
+
235
+ type Section = NodeSection | RelationshipSection;
236
+
237
+ /** The delimiters sniffed between when none is given: neo4j-admin's default and the TSV tab. */
238
+ const NEO4J_DELIMITER_CANDIDATES: readonly string[] = Object.freeze([",", "\t"]);
239
+
240
+ /**
241
+ * Resolve the format-specific options.
242
+ * @param options - the caller's options
243
+ * @returns the resolved options; E_UNSUPPORTED for an invalid value
244
+ */
245
+ function resolveNeo4jOptions(options: Neo4jImportOptions | undefined): ResolvedNeo4jOptions {
246
+ const o: Neo4jImportOptions = options ?? {};
247
+ const arrayDelimiter = o.arrayDelimiter ?? ";";
248
+ const listSyntax = ARRAY_DELIMITERS[arrayDelimiter];
249
+ if (typeof arrayDelimiter !== "string" || listSyntax === undefined) {
250
+ throw new GraphFormatError(
251
+ "E_UNSUPPORTED",
252
+ `option arrayDelimiter: ${JSON.stringify(arrayDelimiter)} is not one of ";", ",", "|"`,
253
+ { option: "arrayDelimiter", found: arrayDelimiter },
254
+ );
255
+ }
256
+ const syntax = checkRecordSyntax({
257
+ delimiter: o.delimiter ?? null,
258
+ quote: o.quote ?? '"',
259
+ candidates: NEO4J_DELIMITER_CANDIDATES.filter((d) => d !== arrayDelimiter),
260
+ });
261
+ if (syntax.delimiter === arrayDelimiter) {
262
+ throw new GraphFormatError("E_UNSUPPORTED", "options delimiter and arrayDelimiter must differ", {
263
+ option: "arrayDelimiter",
264
+ found: arrayDelimiter,
265
+ });
266
+ }
267
+ return {
268
+ nodes: inputList("nodes", o.nodes),
269
+ relationships: inputList("relationships", o.relationships),
270
+ syntax,
271
+ listSyntax,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Normalise an input-list option.
277
+ * @param name - the option name
278
+ * @param value - one input, a list of inputs, or undefined
279
+ * @returns the inputs; E_UNSUPPORTED for anything else
280
+ */
281
+ function inputList(name: string, value: unknown): readonly ImportInput[] {
282
+ if (value === undefined || value === null) {
283
+ return [];
284
+ }
285
+ const list: unknown[] = Array.isArray(value) ? (value as unknown[]) : [value];
286
+ for (const item of list) {
287
+ if (!isImportInput(item)) {
288
+ throw new GraphFormatError("E_UNSUPPORTED", `option ${name}: an entry is not an ImportInput`, {
289
+ option: name,
290
+ found: typeof item,
291
+ });
292
+ }
293
+ }
294
+ return list as ImportInput[];
295
+ }
296
+
297
+ /**
298
+ * Which nodes were declared by a node row and in which id space, by node index, so a repeated id
299
+ * is reported (a duplicate in one space, a collision across spaces).
300
+ */
301
+ class NodeRegistry {
302
+ private codes = new Uint32Array(1024);
303
+
304
+ private readonly spaces = new Map<string | null, number>();
305
+
306
+ /**
307
+ * The code of an id space (1 for "no space").
308
+ * @param space - the space name or null
309
+ * @returns a code >= 1
310
+ */
311
+ codeOf(space: string | null): number {
312
+ let code = this.spaces.get(space);
313
+ if (code === undefined) {
314
+ code = this.spaces.size + 1;
315
+ this.spaces.set(space, code);
316
+ }
317
+ return code;
318
+ }
319
+
320
+ /**
321
+ * Record that a node row declared a node.
322
+ * @param index - the node index
323
+ * @param code - the space code
324
+ * @returns "new" for a first declaration, "duplicate" for a repeat in the same space, "collision" across spaces
325
+ */
326
+ declare(index: number, code: number): "new" | "duplicate" | "collision" {
327
+ if (index >= this.codes.length) {
328
+ let size = this.codes.length * 2;
329
+ while (size <= index) {
330
+ size *= 2;
331
+ }
332
+ const grown = new Uint32Array(size);
333
+ grown.set(this.codes);
334
+ this.codes = grown;
335
+ }
336
+ const previous = this.codes[index];
337
+ if (previous === 0) {
338
+ this.codes[index] = code;
339
+ return "new";
340
+ }
341
+ return previous === code ? "duplicate" : "collision";
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Byte progress over several inputs as one sequence: each input's progress is offset by the bytes
347
+ * of the inputs before it, and the total is known only when every input is in memory.
348
+ */
349
+ class ProgressTracker {
350
+ private readonly callback: ((bytesDone: number, bytesTotal?: number) => void) | null;
351
+
352
+ private readonly total: number | undefined;
353
+
354
+ private offset = 0;
355
+
356
+ private lastDone = 0;
357
+
358
+ /**
359
+ * Create a tracker.
360
+ * @param callback - the caller's onProgress, or null
361
+ * @param inputs - every input in reading order
362
+ */
363
+ constructor(callback: ((bytesDone: number, bytesTotal?: number) => void) | null, inputs: readonly ImportInput[]) {
364
+ this.callback = callback;
365
+ let total: number | undefined = 0;
366
+ for (const input of inputs) {
367
+ const length = inputLength(input);
368
+ if (length === null) {
369
+ total = undefined;
370
+ break;
371
+ }
372
+ total += length;
373
+ }
374
+ this.total = total;
375
+ }
376
+
377
+ /**
378
+ * The read options for one input.
379
+ * @param signal - the cancellation signal
380
+ * @returns options whose onProgress reports cumulative bytes
381
+ */
382
+ optionsFor(signal: AbortSignal | null): ReadOptions {
383
+ const { callback } = this;
384
+ if (callback === null) {
385
+ return { signal };
386
+ }
387
+ return {
388
+ signal,
389
+ onProgress: (done: number): void => {
390
+ this.lastDone = done;
391
+ callback(this.offset + done, this.total);
392
+ },
393
+ };
394
+ }
395
+
396
+ /** Move the offset past the input just finished. */
397
+ finishInput(): void {
398
+ this.offset += this.lastDone;
399
+ this.lastDone = 0;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * The state of one import call: the sink, the report, the resolved options, the reserved column
405
+ * handles and the per-section row handlers.
406
+ */
407
+ class Neo4jImportSession {
408
+ private readonly sink: GraphSink;
409
+
410
+ private readonly report: ImportReportBuilder;
411
+
412
+ private readonly common: ResolvedImportOptions;
413
+
414
+ private readonly options: ResolvedNeo4jOptions;
415
+
416
+ private readonly coercer: IdCoercer;
417
+
418
+ private readonly direction: DirectionResolver;
419
+
420
+ private readonly registry = new NodeRegistry();
421
+
422
+ private labelsHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
423
+
424
+ private typeHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
425
+
426
+ private idSpaceHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
427
+
428
+ private ignoredColumns = 0;
429
+
430
+ /** Whether the sink's direction was set (before the first relationship, design section 8.4 rule 1). */
431
+ private headerSet = false;
432
+
433
+ /** Scratch: the parsed value of every cell of the current row. */
434
+ private values: unknown[] = [];
435
+
436
+ /** Scratch: the companion text of every cell of the current row. */
437
+ private texts: (string | null)[] = [];
438
+
439
+ /** Scratch: the property slots of the current row that lost precision. */
440
+ private readonly precisionSlots: PropertySlot[] = [];
441
+
442
+ /**
443
+ * Create a session.
444
+ * @param sink - the sink
445
+ * @param report - the report
446
+ * @param common - the resolved common options
447
+ * @param options - the resolved format options
448
+ */
449
+ constructor(
450
+ sink: GraphSink,
451
+ report: ImportReportBuilder,
452
+ common: ResolvedImportOptions,
453
+ options: ResolvedNeo4jOptions,
454
+ ) {
455
+ this.sink = sink;
456
+ this.report = report;
457
+ this.common = common;
458
+ this.options = options;
459
+ this.coercer = new IdCoercer(common.ids);
460
+ this.direction = new DirectionResolver(sink, report, common.onMixedDirection);
461
+ }
462
+
463
+ /**
464
+ * Read every input.
465
+ * @param inputs - the inputs in reading order
466
+ */
467
+ async run(inputs: readonly ImportInput[]): Promise<void> {
468
+ const progress = new ProgressTracker(this.common.onProgress, inputs);
469
+ for (const input of inputs) {
470
+ await this.readInput(input, progress.optionsFor(this.common.signal));
471
+ progress.finishInput();
472
+ }
473
+ if (this.ignoredColumns > 0) {
474
+ this.report.loss(
475
+ IGNORED_COLUMNS_LOSS,
476
+ `${this.ignoredColumns} :IGNORE column(s) were skipped as the header instructs`,
477
+ null,
478
+ this.ignoredColumns,
479
+ );
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Read one input: a header row, then data rows until the next header row.
485
+ * @param input - the input
486
+ * @param readOptions - cancellation and progress
487
+ */
488
+ private async readInput(input: ImportInput, readOptions: ReadOptions): Promise<void> {
489
+ const reader = new RecordReader(input, this.report, this.options.syntax, readOptions);
490
+ let section: Section | null = null;
491
+ let sinceCheck = 0;
492
+ for await (const count of reader) {
493
+ if (section === null || isHeaderRecord(reader.cells, count)) {
494
+ section = this.declareSection(reader, count);
495
+ continue;
496
+ }
497
+ if (section.kind === "node") {
498
+ this.nodeRow(section, reader, count);
499
+ } else {
500
+ this.relationshipRow(section, reader, count);
501
+ }
502
+ if (++sinceCheck >= ABORT_CHECK_INTERVAL) {
503
+ sinceCheck = 0;
504
+ throwIfAborted(readOptions.signal);
505
+ }
506
+ }
507
+ if (section === null) {
508
+ this.report.fail(HEADER_CODE, "the input has no header row", { line: 1 });
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Interpret a header row: parse every cell, check the section shape, declare its columns.
514
+ * @param reader - the reader positioned on the header
515
+ * @param count - the number of header cells
516
+ * @returns the section
517
+ */
518
+ private declareSection(reader: RecordReader, count: number): Section {
519
+ const { line } = reader;
520
+ const fields: HeaderField[] = [];
521
+ try {
522
+ for (let i = 0; i < count; i++) {
523
+ fields.push(parseHeaderField(reader.cells[i]));
524
+ }
525
+ const kinds = new Map<FieldKind, number[]>();
526
+ fields.forEach((field, i) => {
527
+ const list = kinds.get(field.kind);
528
+ if (list === undefined) {
529
+ kinds.set(field.kind, [i]);
530
+ } else {
531
+ list.push(i);
532
+ }
533
+ });
534
+ const ids = kinds.get("ID") ?? [];
535
+ const starts = kinds.get("START_ID") ?? [];
536
+ const ends = kinds.get("END_ID") ?? [];
537
+ const isNode = ids.length > 0;
538
+ const isRelationship = starts.length > 0 || ends.length > 0;
539
+ if (isNode && isRelationship) {
540
+ throw headerError("a header mixes :ID with :START_ID / :END_ID");
541
+ }
542
+ if (!isNode && !isRelationship) {
543
+ throw headerError(
544
+ "a header needs an :ID column (nodes) or :START_ID and :END_ID columns (relationships)",
545
+ );
546
+ }
547
+ checkPropertyNames(fields);
548
+ this.ignoredColumns += (kinds.get("IGNORE") ?? []).length;
549
+ if (isNode) {
550
+ if (ids.length > 1) {
551
+ throw headerError("a node header has more than one :ID column");
552
+ }
553
+ if (kinds.has("TYPE")) {
554
+ throw headerError("a node header cannot have a :TYPE column");
555
+ }
556
+ return this.declareNodeSection(fields, ids[0], kinds.get("LABEL") ?? [], line);
557
+ }
558
+ if (starts.length !== 1 || ends.length !== 1) {
559
+ throw headerError("a relationship header needs exactly one :START_ID and one :END_ID column");
560
+ }
561
+ if (kinds.has("LABEL")) {
562
+ throw headerError("a relationship header cannot have a :LABEL column");
563
+ }
564
+ const types = kinds.get("TYPE") ?? [];
565
+ if (types.length > 1) {
566
+ throw headerError("a relationship header has more than one :TYPE column");
567
+ }
568
+ return this.declareRelationshipSection(
569
+ fields,
570
+ starts[0],
571
+ ends[0],
572
+ types.length === 1 ? types[0] : -1,
573
+ line,
574
+ );
575
+ } catch (err) {
576
+ if (err instanceof GraphFormatError && !(err instanceof ImportError)) {
577
+ this.report.fail(HEADER_CODE, `line ${line}: ${err.message}`, { line }, { cause: err.code });
578
+ }
579
+ throw err;
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Declare the columns of a node section.
585
+ * @param fields - the parsed header
586
+ * @param idCell - the `:ID` cell
587
+ * @param labelCells - the `:LABEL` cells
588
+ * @param line - the header line
589
+ * @returns the section
590
+ */
591
+ private declareNodeSection(
592
+ fields: readonly HeaderField[],
593
+ idCell: number,
594
+ labelCells: readonly number[],
595
+ line: number,
596
+ ): NodeSection {
597
+ const idField = fields[idCell];
598
+ const extraLabels: string[] = [];
599
+ for (const field of fields) {
600
+ for (const [key, value] of field.options) {
601
+ if (key === "label" && field.kind === "ID") {
602
+ extraLabels.push(value);
603
+ } else {
604
+ this.report.warning(
605
+ "unsupported",
606
+ HEADER_OPTION_CODE,
607
+ `header option ${key}:${value} of "${field.text}" is ignored`,
608
+ { line, element: field.text },
609
+ );
610
+ }
611
+ }
612
+ }
613
+ if (labelCells.length > 0 || extraLabels.length > 0) {
614
+ this.ensureLabels();
615
+ }
616
+ if (idField.space !== null) {
617
+ this.ensureIdSpace();
618
+ }
619
+ let idHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
620
+ if (idField.name.length > 0) {
621
+ idHandle = this.declareProperty(
622
+ "node",
623
+ { ...idField, type: null },
624
+ idCell,
625
+ { origin: { format: NEO4J, id: idField.name, title: null, type: "ID", namespace: idField.space } },
626
+ line,
627
+ ).handle;
628
+ }
629
+ const properties = this.declareProperties("node", fields, line, null);
630
+ return {
631
+ kind: "node",
632
+ width: fields.length,
633
+ idCell,
634
+ idHandle,
635
+ space: idField.space,
636
+ spaceCode: this.registry.codeOf(idField.space),
637
+ labelCells,
638
+ extraLabels,
639
+ properties,
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Declare the columns of a relationship section.
645
+ * @param fields - the parsed header
646
+ * @param startCell - the `:START_ID` cell
647
+ * @param endCell - the `:END_ID` cell
648
+ * @param typeCell - the `:TYPE` cell, or -1
649
+ * @param line - the header line
650
+ * @returns the section
651
+ */
652
+ private declareRelationshipSection(
653
+ fields: readonly HeaderField[],
654
+ startCell: number,
655
+ endCell: number,
656
+ typeCell: number,
657
+ line: number,
658
+ ): RelationshipSection {
659
+ for (const field of fields) {
660
+ for (const [key, value] of field.options) {
661
+ this.report.warning(
662
+ "unsupported",
663
+ HEADER_OPTION_CODE,
664
+ `header option ${key}:${value} of "${field.text}" is ignored`,
665
+ { line, element: field.text },
666
+ );
667
+ }
668
+ }
669
+ if (typeCell >= 0) {
670
+ this.ensureType();
671
+ }
672
+ const { weightFrom } = this.common;
673
+ let weightCell = -1;
674
+ if (weightFrom !== null) {
675
+ weightCell = fields.findIndex((field) => field.kind === "PROPERTY" && field.name === weightFrom);
676
+ }
677
+ const properties = this.declareProperties("edge", fields, line, weightCell);
678
+ return { kind: "relationship", width: fields.length, startCell, endCell, typeCell, weightCell, properties };
679
+ }
680
+
681
+ /**
682
+ * Declare every PROPERTY field of a header.
683
+ * @param domain - node or edge
684
+ * @param fields - the parsed header
685
+ * @param line - the header line
686
+ * @param skipCell - a cell to leave undeclared (the weight), or null
687
+ * @returns the property slots
688
+ */
689
+ private declareProperties(
690
+ domain: "node" | "edge",
691
+ fields: readonly HeaderField[],
692
+ line: number,
693
+ skipCell: number | null,
694
+ ): PropertySlot[] {
695
+ const slots: PropertySlot[] = [];
696
+ fields.forEach((field, cell) => {
697
+ if (field.kind !== "PROPERTY" || cell === skipCell) {
698
+ return;
699
+ }
700
+ slots.push(this.declareProperty(domain, field, cell, {}, line));
701
+ });
702
+ const width = fields.length;
703
+ if (this.values.length < width) {
704
+ this.values = new Array<unknown>(width);
705
+ this.texts = new Array<string | null>(width);
706
+ }
707
+ return slots;
708
+ }
709
+
710
+ /**
711
+ * Declare one property column on the sink: the same name and shape again shares the column;
712
+ * a different shape under the same name is renamed `<name>#<name>` (design section 5.6).
713
+ * @param domain - node or edge
714
+ * @param field - the header field
715
+ * @param cell - the field's cell index
716
+ * @param patch - declaration fields to override (the id property's origin)
717
+ * @param line - the header line
718
+ * @returns the slot
719
+ */
720
+ private declareProperty(
721
+ domain: "node" | "edge",
722
+ field: HeaderField,
723
+ cell: number,
724
+ patch: Partial<ColumnDecl>,
725
+ line: number,
726
+ ): PropertySlot {
727
+ const { sink, report } = this;
728
+ const { listSyntax } = this.options;
729
+ const input: AttributeDeclarationInput = {
730
+ format: NEO4J,
731
+ id: field.name,
732
+ title: null,
733
+ type: field.type,
734
+ namespace: null,
735
+ listSyntax,
736
+ long: this.common.long,
737
+ };
738
+ let declared: DeclaredAttribute = declareAttribute(input);
739
+ let decl: ColumnDecl = { ...declared.decl, ...patch };
740
+ let handle: ColumnHandle;
741
+ try {
742
+ handle = declareOn(sink, domain, decl);
743
+ } catch (err) {
744
+ if (!(err instanceof GraphFormatError) || err.code !== "E_COLUMN_EXISTS") {
745
+ throw err;
746
+ }
747
+ declared = declareAttribute({ ...input, taken: takenIn(sink, domain) });
748
+ decl = { ...declared.decl, ...patch, name: declared.decl.name };
749
+ handle = declareOn(sink, domain, decl);
750
+ }
751
+ for (const issue of declared.issues) {
752
+ report.warning(issue.category, issue.code, issue.message, { line, element: field.text });
753
+ }
754
+ return {
755
+ cell,
756
+ name: decl.name,
757
+ handle,
758
+ spec: declared.spec,
759
+ companionDecl: declared.companion,
760
+ companion: INVALID_INDEX as ColumnHandle,
761
+ };
762
+ }
763
+
764
+ /** Declare (or adopt) the labels column. */
765
+ private ensureLabels(): void {
766
+ if (this.labelsHandle === INVALID_INDEX) {
767
+ this.labelsHandle = this.declareReserved("node", LABELS_DECL);
768
+ }
769
+ }
770
+
771
+ /** Declare (or adopt) the relationship type column. */
772
+ private ensureType(): void {
773
+ if (this.typeHandle === INVALID_INDEX) {
774
+ this.typeHandle = this.declareReserved("edge", TYPE_DECL);
775
+ }
776
+ }
777
+
778
+ /** Declare (or adopt) the id space column. */
779
+ private ensureIdSpace(): void {
780
+ if (this.idSpaceHandle === INVALID_INDEX) {
781
+ this.idSpaceHandle = this.declareReserved("node", ID_SPACE_DECL);
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Declare a reserved column: the same shape again shares it; a name taken by another shape is
787
+ * renamed `<name>#<origin.id>` and reported; a role the sink already holds elsewhere is dropped
788
+ * and reported.
789
+ * @param domain - node or edge
790
+ * @param decl - the reserved declaration
791
+ * @returns the handle
792
+ */
793
+ private declareReserved(domain: "node" | "edge", decl: ColumnDecl): ColumnHandle {
794
+ const { sink, report } = this;
795
+ let current = decl;
796
+ for (;;) {
797
+ try {
798
+ return declareOn(sink, domain, current);
799
+ } catch (err) {
800
+ if (!(err instanceof GraphFormatError)) {
801
+ throw err;
802
+ }
803
+ if (err.code === "E_COLUMN_EXISTS") {
804
+ const name = uniqueColumnName(current.name, current.origin?.id ?? null, takenIn(sink, domain));
805
+ report.warning(
806
+ "coercion",
807
+ RENAMED_CODE,
808
+ `column "${current.name}" renamed to "${name}": the name was taken`,
809
+ { element: current.name },
810
+ );
811
+ current = { ...current, name };
812
+ } else if (err.code === "E_DUPLICATE_ROLE") {
813
+ report.warning(
814
+ "coercion",
815
+ ROLE_TAKEN_CODE,
816
+ `column "${current.name}" declared without role "${String(current.role)}": the sink already holds that role`,
817
+ { element: current.name },
818
+ );
819
+ const { role: _role, ...withoutRole } = current;
820
+ current = withoutRole;
821
+ } else {
822
+ throw err;
823
+ }
824
+ }
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Push one node row.
830
+ * @param section - the section
831
+ * @param reader - the reader positioned on the row
832
+ * @param count - the row's cell count
833
+ */
834
+ private nodeRow(section: NodeSection, reader: RecordReader, count: number): void {
835
+ const { sink, report } = this;
836
+ const { cells, quoted, line } = reader;
837
+ if (count !== section.width) {
838
+ report.error(
839
+ "validation-error",
840
+ COLUMN_COUNT_CODE,
841
+ `row has ${count} cell(s) but the header has ${section.width}`,
842
+ { line },
843
+ );
844
+ report.counts.skippedNodes++;
845
+ return;
846
+ }
847
+ const idText = cells[section.idCell];
848
+ if (idText.length === 0 && !quoted[section.idCell]) {
849
+ // a quoted empty cell is the id "" (neo4j-admin: an empty quoted field is an empty string)
850
+ report.error("missing-value", MISSING_ID_CODE, "empty :ID cell", { line });
851
+ report.counts.skippedNodes++;
852
+ return;
853
+ }
854
+ const id = this.coerceId(idText, line);
855
+ if (id === null || !this.parseProperties(section.properties, cells, quoted, line, idText)) {
856
+ report.counts.skippedNodes++;
857
+ return;
858
+ }
859
+ let labels: string[] | undefined;
860
+ if (section.labelCells.length > 0 || section.extraLabels.length > 0) {
861
+ labels = this.labelsOf(section, cells, quoted);
862
+ }
863
+ const index = sink.addNode(id);
864
+ const status = this.registry.declare(index, section.spaceCode);
865
+ if (status === "collision") {
866
+ report.error(
867
+ "validation-error",
868
+ ID_SPACE_COLLISION_CODE,
869
+ `node ${idText} is declared in id space ${section.space ?? "(none)"} and in another id space; the core has one id space and this row is skipped`,
870
+ { line, element: idText },
871
+ );
872
+ report.counts.skippedNodes++;
873
+ return;
874
+ }
875
+ if (status === "duplicate") {
876
+ report.warning(
877
+ "merged",
878
+ DUPLICATE_NODE_CODE,
879
+ `node ${idText} is declared twice; the later properties win`,
880
+ {
881
+ line,
882
+ element: idText,
883
+ },
884
+ );
885
+ }
886
+ if (section.idHandle !== INVALID_INDEX) {
887
+ sink.setNodeValue(section.idHandle, index, idText);
888
+ }
889
+ if (section.space !== null) {
890
+ sink.setNodeValue(this.idSpaceHandle, index, section.space);
891
+ }
892
+ if (labels !== undefined) {
893
+ sink.setNodeValue(this.labelsHandle, index, labels);
894
+ }
895
+ this.writeProperties("node", section.properties, index);
896
+ this.reportPrecision(line, idText);
897
+ report.counts.nodes++;
898
+ }
899
+
900
+ /**
901
+ * Push one relationship row.
902
+ * @param section - the section
903
+ * @param reader - the reader positioned on the row
904
+ * @param count - the row's cell count
905
+ */
906
+ private relationshipRow(section: RelationshipSection, reader: RecordReader, count: number): void {
907
+ const { sink, report } = this;
908
+ const { cells, quoted, line } = reader;
909
+ if (!this.headerSet) {
910
+ // a Neo4j file is directed by definition ("all relationships have a direction"); the sink's
911
+ // direction is set once, before the first relationship, so a node-only file leaves it alone
912
+ this.headerSet = true;
913
+ this.direction.setHeader(true, { line });
914
+ }
915
+ if (count !== section.width) {
916
+ report.error(
917
+ "validation-error",
918
+ COLUMN_COUNT_CODE,
919
+ `row has ${count} cell(s) but the header has ${section.width}`,
920
+ { line },
921
+ );
922
+ report.counts.skippedEdges++;
923
+ return;
924
+ }
925
+ const startText = cells[section.startCell];
926
+ const endText = cells[section.endCell];
927
+ const startMissing = startText.length === 0 && !quoted[section.startCell];
928
+ if (startMissing || (endText.length === 0 && !quoted[section.endCell])) {
929
+ report.error(
930
+ "missing-value",
931
+ MISSING_ENDPOINT_CODE,
932
+ `empty ${startMissing ? ":START_ID" : ":END_ID"} cell`,
933
+ {
934
+ line,
935
+ },
936
+ );
937
+ report.counts.skippedEdges++;
938
+ return;
939
+ }
940
+ const element = `${startText}->${endText}`;
941
+ const source = this.coerceId(startText, line);
942
+ const target = source === null ? null : this.coerceId(endText, line);
943
+ if (source === null || target === null) {
944
+ report.counts.skippedEdges++;
945
+ return;
946
+ }
947
+ let weight: number | undefined;
948
+ if (section.weightCell >= 0) {
949
+ try {
950
+ weight = parseWeightText(cells[section.weightCell]);
951
+ } catch (err) {
952
+ report.recordError(err, { line, element });
953
+ report.counts.skippedEdges++;
954
+ return;
955
+ }
956
+ }
957
+ if (!this.parseProperties(section.properties, cells, quoted, line, element)) {
958
+ report.counts.skippedEdges++;
959
+ return;
960
+ }
961
+ let edge: number;
962
+ try {
963
+ edge = this.direction.addEdge(source, target, "directed", weight, { line, element });
964
+ } catch (err) {
965
+ report.recordError(err, { line, element });
966
+ report.counts.skippedEdges++;
967
+ return;
968
+ }
969
+ if (section.typeCell >= 0) {
970
+ const type = cells[section.typeCell];
971
+ if (type.length > 0) {
972
+ sink.setEdgeValue(this.typeHandle, edge, type);
973
+ }
974
+ }
975
+ this.writeProperties("edge", section.properties, edge);
976
+ this.reportPrecision(line, element);
977
+ report.counts.edges++;
978
+ }
979
+
980
+ /**
981
+ * Coerce an id cell, reporting a merge under `ids: "number"` and an invalid id as an error.
982
+ * @param text - the cell
983
+ * @param line - the row's line
984
+ * @returns the id, or null when the cell was rejected (the error is recorded)
985
+ */
986
+ private coerceId(text: string, line: number): NodeId | null {
987
+ const { coercer, report } = this;
988
+ let id: NodeId;
989
+ try {
990
+ id = coercer.text(text);
991
+ } catch (err) {
992
+ report.recordError(err, { line, element: text });
993
+ return null;
994
+ }
995
+ const merge = coercer.lastMerge;
996
+ if (merge !== null) {
997
+ report.warning(
998
+ "coercion",
999
+ ID_MERGED_CODE,
1000
+ `id "${merge.text}" merged with "${merge.previousText}" as ${merge.id} under ids: "number"`,
1001
+ { line, element: text },
1002
+ );
1003
+ }
1004
+ return id;
1005
+ }
1006
+
1007
+ /**
1008
+ * Parse the property cells of a row into the scratch arrays.
1009
+ * @param slots - the section's property slots
1010
+ * @param cells - the row's cells
1011
+ * @param quoted - whether each cell was quoted
1012
+ * @param line - the row's line
1013
+ * @param element - the row's element name for issues
1014
+ * @returns true when every cell parsed; false after recording the first error
1015
+ */
1016
+ private parseProperties(
1017
+ slots: readonly PropertySlot[],
1018
+ cells: readonly string[],
1019
+ quoted: readonly boolean[],
1020
+ line: number,
1021
+ element: string,
1022
+ ): boolean {
1023
+ const { values, texts, precisionSlots } = this;
1024
+ precisionSlots.length = 0;
1025
+ for (const slot of slots) {
1026
+ const text = cells[slot.cell];
1027
+ texts[slot.cell] = null;
1028
+ if (text.length === 0) {
1029
+ values[slot.cell] = quoted[slot.cell] ? emptyValue(slot.spec) : undefined;
1030
+ continue;
1031
+ }
1032
+ const { spec } = slot;
1033
+ try {
1034
+ if (spec.temporal !== null && !spec.list) {
1035
+ const parsed = parseDeclaredTemporal(text, spec);
1036
+ values[slot.cell] = parsed.value;
1037
+ texts[slot.cell] = parsed.text;
1038
+ } else {
1039
+ values[slot.cell] = parseDeclaredValue(text, spec, this.options.listSyntax);
1040
+ }
1041
+ } catch (err) {
1042
+ this.report.recordError(err, { line, element: `${element} ${slot.name}` });
1043
+ return false;
1044
+ }
1045
+ if (spec.precision && losesPrecision(spec, text)) {
1046
+ precisionSlots.push(slot);
1047
+ }
1048
+ }
1049
+ return true;
1050
+ }
1051
+
1052
+ /**
1053
+ * Write the parsed property values of a row.
1054
+ * @param domain - node or edge
1055
+ * @param slots - the section's property slots
1056
+ * @param row - the node or edge index
1057
+ */
1058
+ private writeProperties(domain: "node" | "edge", slots: readonly PropertySlot[], row: number): void {
1059
+ const { sink, values, texts } = this;
1060
+ for (const slot of slots) {
1061
+ const value = values[slot.cell];
1062
+ if (value === undefined) {
1063
+ continue;
1064
+ }
1065
+ const text = texts[slot.cell];
1066
+ if (text !== null && slot.companion === INVALID_INDEX && slot.companionDecl !== null) {
1067
+ slot.companion = declareCompanion(sink, domain, slot.companionDecl);
1068
+ }
1069
+ if (domain === "node") {
1070
+ sink.setNodeValue(slot.handle, row, value);
1071
+ if (text !== null) {
1072
+ sink.setNodeValue(slot.companion, row, text);
1073
+ }
1074
+ } else {
1075
+ sink.setEdgeValue(slot.handle, row, value);
1076
+ if (text !== null) {
1077
+ sink.setEdgeValue(slot.companion, row, text);
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * Report the precision losses of the row just accepted.
1085
+ * @param line - the row's line
1086
+ * @param element - the row's element name
1087
+ */
1088
+ private reportPrecision(line: number, element: string): void {
1089
+ for (const slot of this.precisionSlots) {
1090
+ this.report.warning(
1091
+ "precision",
1092
+ PRECISION_CODE,
1093
+ `long value of "${slot.name}" is beyond 2^53 and was stored as the nearest f64`,
1094
+ { line, element },
1095
+ );
1096
+ }
1097
+ this.precisionSlots.length = 0;
1098
+ }
1099
+
1100
+ /**
1101
+ * The labels of a node row: every `:LABEL` cell split by the array delimiter (empty items
1102
+ * dropped), plus the header's `{label:...}` options.
1103
+ * @param section - the section
1104
+ * @param cells - the row's cells
1105
+ * @param quoted - whether each cell was quoted
1106
+ * @returns the labels, or undefined when every label cell is unset and no extra label exists
1107
+ */
1108
+ private labelsOf(section: NodeSection, cells: readonly string[], quoted: readonly boolean[]): string[] | undefined {
1109
+ let labels: string[] | undefined;
1110
+ for (const cell of section.labelCells) {
1111
+ const text = cells[cell];
1112
+ if (text.length === 0 && !quoted[cell]) {
1113
+ continue;
1114
+ }
1115
+ if (labels === undefined) {
1116
+ labels = [];
1117
+ }
1118
+ for (const item of splitListText(text, this.options.listSyntax)) {
1119
+ if (item.length > 0) {
1120
+ labels.push(item);
1121
+ }
1122
+ }
1123
+ }
1124
+ if (section.extraLabels.length > 0) {
1125
+ if (labels === undefined) {
1126
+ labels = [];
1127
+ }
1128
+ labels.push(...section.extraLabels);
1129
+ }
1130
+ return labels;
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * The value of a quoted empty cell: an empty string for a string-kind property, an empty list for
1136
+ * a list property, unset for anything else (neo4j-admin's default `--ignore-empty-strings=false`).
1137
+ * @param spec - the property's declared type
1138
+ * @returns the value, or undefined for unset
1139
+ */
1140
+ function emptyValue(spec: DeclaredTypeSpec): unknown {
1141
+ if (spec.list) {
1142
+ return [];
1143
+ }
1144
+ if (spec.kind === "string" || spec.kind === "duration") {
1145
+ return "";
1146
+ }
1147
+ return undefined;
1148
+ }
1149
+
1150
+ /**
1151
+ * Check that no two PROPERTY (or stored-id) fields of one header share a name.
1152
+ * @param fields - the parsed header
1153
+ */
1154
+ function checkPropertyNames(fields: readonly HeaderField[]): void {
1155
+ const seen = new Set<string>();
1156
+ for (const field of fields) {
1157
+ if (field.name.length === 0 || field.kind === "IGNORE") {
1158
+ continue;
1159
+ }
1160
+ if (seen.has(field.name)) {
1161
+ throw headerError(`property "${field.name}" is declared twice`);
1162
+ }
1163
+ seen.add(field.name);
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * The error of a malformed section header.
1169
+ * @param reason - why
1170
+ * @returns the error
1171
+ */
1172
+ function headerError(reason: string): GraphFormatError {
1173
+ return new GraphFormatError("E_UNSUPPORTED", reason, { reason: "header" });
1174
+ }
1175
+
1176
+ /**
1177
+ * Confidence that a head of bytes is a neo4j-admin CSV: the first line holds an `:ID`,
1178
+ * `:START_ID` or `:END_ID` header cell.
1179
+ * @param head - the first bytes of the input
1180
+ * @returns 0.95 for a Neo4j header, 0 otherwise
1181
+ */
1182
+ function sniffNeo4j(head: Uint8Array): number {
1183
+ const text = new TextDecoder("utf-8", { fatal: false, ignoreBOM: false }).decode(head);
1184
+ const end = text.search(/[\r\n]/);
1185
+ const first = end < 0 ? text : text.slice(0, end);
1186
+ const cells = first.split(/[,;|\t]/);
1187
+ return isHeaderRecord(cells, cells.length) ? 0.95 : 0;
1188
+ }
1189
+
1190
+ /** The Neo4j importer. */
1191
+ export const neo4jImporter: GraphImporter<Neo4jImportOptions> = Object.freeze({
1192
+ format: NEO4J,
1193
+ extensions: Object.freeze([".csv", ".tsv"]),
1194
+ mimeTypes: Object.freeze(["text/csv", "text/tab-separated-values"]),
1195
+ sniff: sniffNeo4j,
1196
+ /**
1197
+ * Read one or more neo4j-admin CSV inputs into the sink.
1198
+ * @param input - the primary input (a node table, a relationship table, or sections of both)
1199
+ * @param sink - the sink
1200
+ * @param options - format-specific and common options
1201
+ * @returns the report; ImportError beyond the error limit or on a malformed header
1202
+ */
1203
+ async import(
1204
+ input: ImportInput,
1205
+ sink: GraphSink,
1206
+ options?: Neo4jImportOptions & CommonImportOptions,
1207
+ ): Promise<ImportReport> {
1208
+ const common = resolveImportOptions(options, { ids: "canonical", defaultDirected: true, weightFrom: "weight" });
1209
+ const format = resolveNeo4jOptions(options);
1210
+ const report = new ImportReportBuilder(NEO4J, common.errorLimit);
1211
+ reportSinkOptions(sink, options, report);
1212
+ // nodeIdFrom and defaultDirected are among the reported options: Neo4j ids are the :ID
1213
+ // column and every relationship is directed
1214
+ reportUnusedOptions(options, report, USED_OPTIONS);
1215
+ const session = new Neo4jImportSession(sink, report, common, format);
1216
+ await session.run([input, ...format.nodes, ...format.relationships]);
1217
+ throwIfAborted(common.signal);
1218
+ return report.finish();
1219
+ },
1220
+ });