@graphty/graph-io 0.3.1 → 0.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/graph-io",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Importers and exporters (GEXF, GraphML, GML, DOT, Pajek, CSV, JSON, Neo4j) for the @graphty/graph-format snapshot",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -88,16 +88,40 @@ export function quoteGmlString(text: string): string {
88
88
  return `"${escaped}"`;
89
89
  }
90
90
 
91
- const GML_ENTITY = /&(#[0-9]+|#x[0-9a-fA-F]+|amp|quot|lt|gt|apos);/g;
92
- const GML_NAMED: Readonly<Record<string, string>> = { amp: "&", quot: '"', lt: "<", gt: ">", apos: "'" };
91
+ const GML_ENTITY = /&(#[0-9]+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);/g;
92
+
93
+ /** The XML entities and the ISO-8859-1 HTML entities (U+00A0..U+00FF), which GML uses for characters above 127. */
94
+ const GML_NAMED: ReadonlyMap<string, string> = (() => {
95
+ const table = new Map<string, string>([
96
+ ["amp", "&"],
97
+ ["quot", '"'],
98
+ ["lt", "<"],
99
+ ["gt", ">"],
100
+ ["apos", "'"],
101
+ ]);
102
+ const latin1 = (
103
+ "nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 " +
104
+ "sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc " +
105
+ "Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve " +
106
+ "Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute " +
107
+ "acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde " +
108
+ "ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml"
109
+ ).split(" ");
110
+ latin1.forEach((name, i) => {
111
+ table.set(name, String.fromCharCode(0xa0 + i));
112
+ });
113
+ return table;
114
+ })();
93
115
 
94
116
  /**
95
117
  * Decode the character entities of a GML string body (the inverse of quoteGmlString on the text
96
- * between the quotes). An unknown entity is left as written.
118
+ * between the quotes): numeric references, the XML entities and the ISO-8859-1 HTML entities. An
119
+ * unknown named entity is left as written and handed to `onUnknown`.
97
120
  * @param body - the text between the quotes
121
+ * @param onUnknown - called with each unknown named entity (`&name;`), when given
98
122
  * @returns the decoded text
99
123
  */
100
- export function decodeGmlString(body: string): string {
124
+ export function decodeGmlString(body: string, onUnknown?: (entity: string) => void): string {
101
125
  return body.replace(GML_ENTITY, (whole, entity: string) => {
102
126
  if (entity.startsWith("#x")) {
103
127
  return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
@@ -105,7 +129,12 @@ export function decodeGmlString(body: string): string {
105
129
  if (entity.startsWith("#")) {
106
130
  return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
107
131
  }
108
- return GML_NAMED[entity] ?? whole;
132
+ const known = GML_NAMED.get(entity);
133
+ if (known === undefined) {
134
+ onUnknown?.(whole);
135
+ return whole;
136
+ }
137
+ return known;
109
138
  });
110
139
  }
111
140
 
@@ -108,6 +108,8 @@ export const ELEMENT_TYPE_CODE = "E_GML_ELEMENT_TYPE";
108
108
  export const FLAG_TYPE_CODE = "E_GML_FLAG_TYPE";
109
109
  /** Issue code: a `directed` / `multigraph` flag that is not 0 or 1 (read as its truth value), or one repeated. */
110
110
  export const FLAG_VALUE_CODE = "W_GML_FLAG_VALUE";
111
+ /** Issue code: a named entity in a string that is neither an XML nor an ISO-8859-1 HTML entity; it is kept as written. */
112
+ export const UNKNOWN_ENTITY_CODE = "W_GML_UNKNOWN_ENTITY";
111
113
  /** Issue code: an integer beyond 2^53 stored as the nearest f64 (design section 5.1). */
112
114
  export const PRECISION_CODE = SHARED_PRECISION_CODE;
113
115
  /** Issue code: the sink already holds a column of the name with another declaration; renamed `<name>#<key>`. */
@@ -342,6 +344,15 @@ class GmlImport {
342
344
  */
343
345
  run(tokens: GmlTokens): void {
344
346
  this.tokens = tokens;
347
+ tokens.onUnknownEntity = (entity, line) => {
348
+ this.report.warnOnce(
349
+ "parse-error",
350
+ UNKNOWN_ENTITY_CODE,
351
+ `unknown character entity ${entity} is kept as written`,
352
+ { line, element: entity },
353
+ `${UNKNOWN_ENTITY_CODE}:${entity}`,
354
+ );
355
+ };
345
356
  this.scan();
346
357
  this.push();
347
358
  }
@@ -438,7 +449,7 @@ class GmlImport {
438
449
  continue;
439
450
  }
440
451
  } else {
441
- if (key === "source" || key === "target") {
452
+ if (key === "source" || key === "target" || key === "directed") {
442
453
  continue;
443
454
  }
444
455
  if (key === weightFrom) {
@@ -717,7 +728,7 @@ class GmlImport {
717
728
  this.report.error(
718
729
  "validation-error",
719
730
  FLAG_TYPE_CODE,
720
- `graph flag "${name}" must be the integer 0 or 1, found ${describeValue(t, v)}`,
731
+ `flag "${name}" must be the integer 0 or 1, found ${describeValue(t, v)}`,
721
732
  { line: t.line[v], element: name },
722
733
  );
723
734
  return null;
@@ -727,7 +738,7 @@ class GmlImport {
727
738
  this.report.warning(
728
739
  "validation-error",
729
740
  FLAG_VALUE_CODE,
730
- `graph flag "${name}" is ${n}; read as ${n !== 0 ? "1" : "0"}`,
741
+ `flag "${name}" is ${n}; read as ${n !== 0 ? "1" : "0"}`,
731
742
  { line: t.line[v], element: name },
732
743
  );
733
744
  }
@@ -931,12 +942,15 @@ class GmlImport {
931
942
  let sourceTok = -1;
932
943
  let targetTok = -1;
933
944
  let weightTok = -1;
945
+ let directedTok = -1;
934
946
  for (let p = open + 1; p < close; p = t.nextPair(p)) {
935
947
  const key = t.textOf(p);
936
948
  if (key === "source") {
937
949
  sourceTok = this.structuralToken(sourceTok, p, "source");
938
950
  } else if (key === "target") {
939
951
  targetTok = this.structuralToken(targetTok, p, "target");
952
+ } else if (key === "directed") {
953
+ directedTok = this.structuralToken(directedTok, p, "directed");
940
954
  } else if (key === options.weightFrom) {
941
955
  weightTok = p + 1;
942
956
  }
@@ -952,13 +966,12 @@ class GmlImport {
952
966
  const before = sink.edgeCount;
953
967
  const sourceNew = sink.indexOf(source) === INVALID_INDEX;
954
968
  const targetNew = source !== target && sink.indexOf(target) === INVALID_INDEX;
955
- edge = this.requireResolver().addEdge(
956
- source,
957
- target,
958
- this.headerDirected ? "directed" : "undirected",
959
- weight,
960
- { line, element },
961
- );
969
+ // an edge-level `directed` key overrides the graph's flag for that edge (mixed graphs)
970
+ const directed = this.readFlag(directedTok, "directed") ?? this.headerDirected;
971
+ edge = this.requireResolver().addEdge(source, target, directed ? "directed" : "undirected", weight, {
972
+ line,
973
+ element,
974
+ });
962
975
  report.counts.edges += sink.edgeCount - before;
963
976
  // endpoints the file never declares (addMissingNodes) are nodes of the sink too
964
977
  report.counts.nodes += (sourceNew ? 1 : 0) + (targetNew ? 1 : 0);
@@ -971,7 +984,7 @@ class GmlImport {
971
984
  try {
972
985
  for (let p = open + 1; p < close; p = t.nextPair(p)) {
973
986
  const key = t.textOf(p);
974
- if (key === "source" || key === "target" || key === options.weightFrom) {
987
+ if (key === "source" || key === "target" || key === "directed" || key === options.weightFrom) {
975
988
  continue;
976
989
  }
977
990
  const plan = this.edgePlans.get(key);
@@ -45,6 +45,7 @@ import {
45
45
  REPEATED_KEY_CODE,
46
46
  ROLE_TAKEN_CODE,
47
47
  SECOND_GRAPH_CODE,
48
+ UNKNOWN_ENTITY_CODE,
48
49
  } from "./importer.js";
49
50
 
50
51
  export { gmlExporter, type GmlExportOptions } from "./exporter.js";
@@ -88,6 +89,8 @@ export const GML_ISSUE = Object.freeze({
88
89
  FLAG_TYPE: FLAG_TYPE_CODE,
89
90
  /** A `directed` / `multigraph` flag outside 0 / 1, or repeated. */
90
91
  FLAG_VALUE: FLAG_VALUE_CODE,
92
+ /** A named entity in a string that no table decodes; kept as written. */
93
+ UNKNOWN_ENTITY: UNKNOWN_ENTITY_CODE,
91
94
  /** An integer beyond 2^53 rounded to f64. */
92
95
  PRECISION: PRECISION_CODE,
93
96
  /** A column renamed `<name>#<key>` because the sink held the name with another shape. */
@@ -112,6 +112,9 @@ export class GmlTokens {
112
112
  /** The number of tokens. */
113
113
  count = 0;
114
114
 
115
+ /** Called by stringOf() with each named entity it cannot decode and the token's line, when set. */
116
+ onUnknownEntity: ((entity: string, line: number) => void) | null = null;
117
+
115
118
  /**
116
119
  * Create an empty token list over a text.
117
120
  * @param text - the source text
@@ -159,7 +162,12 @@ export class GmlTokens {
159
162
  * @returns the body with character references decoded
160
163
  */
161
164
  stringOf(i: number): string {
162
- return decodeGmlString(this.textOf(i));
165
+ const text = this.textOf(i);
166
+ if (text.indexOf("&") < 0) {
167
+ return text;
168
+ }
169
+ const report = this.onUnknownEntity;
170
+ return decodeGmlString(text, report === null ? undefined : (entity) => { report(entity, this.line[i]); });
163
171
  }
164
172
 
165
173
  /**
@@ -173,6 +173,10 @@ export const GRAPHML_ISSUE = Object.freeze({
173
173
  HYPEREDGE_ENDPOINT: "E_GRAPHML_HYPEREDGE_ENDPOINT",
174
174
  /** `<port>` declarations (and their data) are not kept; sourceport / targetport edge attributes are. */
175
175
  PORT_DECLARATION: "W_GRAPHML_PORT_DECLARATION",
176
+ /** A GraphML `parse.*` hint on `<graph>`, `<node>` or `<edge>` the importer does not act on (parse.nodeids, parse.order, ...). */
177
+ PARSE_HINT_IGNORED: "W_GRAPHML_PARSE_HINT_IGNORED",
178
+ /** An XML attribute GraphML does not define on `<graph>`, `<node>` or `<edge>`; it is not kept. */
179
+ UNKNOWN_XML_ATTRIBUTE: "W_GRAPHML_UNKNOWN_XML_ATTRIBUTE",
176
180
  /** A `<locator>` element. */
177
181
  LOCATOR_DROPPED: "W_GRAPHML_LOCATOR_DROPPED",
178
182
  /** A `<desc>` of a node, an edge or a hyperedge. */
@@ -83,6 +83,12 @@ import {
83
83
  } from "./constants.js";
84
84
  import { XmlTreeBuilder } from "./tree.js";
85
85
 
86
+
87
+ /** The XML attributes the importer reads on `<graph>`, `<node>` and `<edge>`; any other is reported. */
88
+ const GRAPH_ATTRIBUTES: ReadonlySet<string> = new Set(["id", "edgedefault", "parse.nodes", "parse.edges"]);
89
+ const NODE_ATTRIBUTES: ReadonlySet<string> = new Set(["id"]);
90
+ const EDGE_ATTRIBUTES: ReadonlySet<string> = new Set(["id", "source", "target", "directed", "sourceport", "targetport"]);
91
+
86
92
  /** The format-specific options of the GraphML importer. */
87
93
  export interface GraphmlImportOptions {
88
94
  /**
@@ -827,6 +833,39 @@ class GraphmlReader implements XmlHandler {
827
833
 
828
834
  // ------------------------------------------------------------------ graphs
829
835
 
836
+ /**
837
+ * Report, once per element kind and attribute name, an XML attribute the importer does not read:
838
+ * a GraphML `parse.*` hint as ignored, anything else as unknown. Namespace declarations and
839
+ * `xml:` / `xsi:` attributes are not data and pass silently.
840
+ * @param kind - the element name
841
+ * @param attrs - the element's attributes
842
+ * @param known - the attributes the importer reads on that element
843
+ * @param line - the line
844
+ */
845
+ private reportUnreadAttributes(
846
+ kind: string,
847
+ attrs: ReadonlyMap<string, string>,
848
+ known: ReadonlySet<string>,
849
+ line: number,
850
+ ): void {
851
+ for (const name of attrs.keys()) {
852
+ if (known.has(name) || name === "xmlns" || /^(xmlns|xml|xsi):/.test(name)) {
853
+ continue;
854
+ }
855
+ const hint = name.startsWith("parse.");
856
+ const code = hint ? GRAPHML_ISSUE.PARSE_HINT_IGNORED : GRAPHML_ISSUE.UNKNOWN_XML_ATTRIBUTE;
857
+ this.report.warnOnce(
858
+ "unsupported",
859
+ code,
860
+ hint
861
+ ? `the <${kind}> parse hint ${name} is ignored`
862
+ : `the <${kind}> attribute ${name} is not a GraphML attribute; it is not kept`,
863
+ { line, element: name },
864
+ `${code}:${kind}:${name}`,
865
+ );
866
+ }
867
+ }
868
+
830
869
  /**
831
870
  * Open a `<graph>`: the top-level one sets the sink's direction (rule 1 of design section
832
871
  * 8.4); a nested one records its container as the parent of its nodes.
@@ -835,6 +874,7 @@ class GraphmlReader implements XmlHandler {
835
874
  * @param parent - the containing node's index, or INVALID_INDEX
836
875
  */
837
876
  private beginGraph(attrs: ReadonlyMap<string, string>, line: number, parent: number): void {
877
+ this.reportUnreadAttributes("graph", attrs, GRAPH_ATTRIBUTES, line);
838
878
  const top = this.graphs.length === 0;
839
879
  const edgedefault = attrs.get("edgedefault");
840
880
  let directed: boolean;
@@ -1409,6 +1449,7 @@ class GraphmlReader implements XmlHandler {
1409
1449
  * @param line - the line
1410
1450
  */
1411
1451
  private beginNode(attrs: ReadonlyMap<string, string>, line: number): void {
1452
+ this.reportUnreadAttributes("node", attrs, NODE_ATTRIBUTES, line);
1412
1453
  const graph = this.graphs[this.graphs.length - 1];
1413
1454
  const state: NodeState = {
1414
1455
  id: null,
@@ -1530,6 +1571,7 @@ class GraphmlReader implements XmlHandler {
1530
1571
  * @param line - the line
1531
1572
  */
1532
1573
  private beginEdge(attrs: ReadonlyMap<string, string>, line: number): void {
1574
+ this.reportUnreadAttributes("edge", attrs, EDGE_ATTRIBUTES, line);
1533
1575
  const graph = this.graphs[this.graphs.length - 1];
1534
1576
  const edge: EdgeState = {
1535
1577
  source: null,
@@ -157,6 +157,8 @@ export const JSON_ISSUE = Object.freeze({
157
157
  HYPEREDGE_SHAPE: "E_HYPEREDGE_SHAPE",
158
158
  /** The nodes have no id key at all; array positions became the ids. */
159
159
  POSITIONAL_NODES: "W_POSITIONAL_NODES",
160
+ /** A node-link / d3 top-level key the importer does not read (the other of edges / links, an unknown key); it is dropped. */
161
+ UNREAD_KEY: "W_JSON_UNREAD_KEY",
160
162
  /** A builder-policy option (addMissingNodes, duplicateEdges, selfLoops, weightDtype) differs from the sink's (the shared W_SINK_OPTION). */
161
163
  SINK_OPTION: SINK_OPTION_CODE,
162
164
  /** A common option the dialect has no use for (nodeIdFrom outside node-link, long, restoreMangledIds). */
@@ -1120,6 +1122,17 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: JsonDiale
1120
1122
  const multigraph = hasKey(root, "multigraph") ? flagOf(root.multigraph, "multigraph", false, report) : null;
1121
1123
  ctx.setHeader(directed);
1122
1124
  ctx.writeGraphDict(root.graph, "graph");
1125
+ for (const key of Object.keys(root)) {
1126
+ if (key !== "nodes" && key !== edgesKey && key !== "directed" && key !== "multigraph" && key !== "graph") {
1127
+ const value = root[key];
1128
+ const what = Array.isArray(value)
1129
+ ? `${String(value.length)} ${value.length === 1 ? "entry" : "entries"}`
1130
+ : describe(value);
1131
+ report.warning("unsupported", JSON_ISSUE.UNREAD_KEY, `top-level key ${key} (${what}) is not read; dropped`, {
1132
+ element: key,
1133
+ });
1134
+ }
1135
+ }
1123
1136
 
1124
1137
  const nodeList = nodes ?? [];
1125
1138
  const edgeList = edges ?? [];
@@ -122,6 +122,12 @@ export const DUPLICATE_NODE_CODE = SHARED_DUPLICATE_NODE_CODE;
122
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
123
  export const ID_SPACE_COLLISION_CODE = "E_NEO4J_ID_SPACE_COLLISION";
124
124
 
125
+ /**
126
+ * Issue code: a `:START_ID(Space)` / `:END_ID(Space)` id names a node a node row declared in another
127
+ * id space; the core has one id space, so the endpoint does not exist in its space and the row is skipped.
128
+ */
129
+ export const ENDPOINT_SPACE_CODE = "E_NEO4J_ENDPOINT_SPACE";
130
+
125
131
  /** Issue code: two different id cells became one id under `ids: "number"`. */
126
132
  export const ID_MERGED_CODE = SHARED_ID_MERGED_CODE;
127
133
 
@@ -225,6 +231,9 @@ interface RelationshipSection {
225
231
  readonly width: number;
226
232
  readonly startCell: number;
227
233
  readonly endCell: number;
234
+ /** The id space of `:START_ID` / `:END_ID` as a registry code, or 0 when the header declares none. */
235
+ readonly startSpaceCode: number;
236
+ readonly endSpaceCode: number;
228
237
  /** The `:TYPE` cell, or -1. */
229
238
  readonly typeCell: number;
230
239
  /** The cell of the property named by `weightFrom`, or -1. */
@@ -340,6 +349,15 @@ class NodeRegistry {
340
349
  }
341
350
  return previous === code ? "duplicate" : "collision";
342
351
  }
352
+
353
+ /**
354
+ * The space code a node row declared a node in.
355
+ * @param index - the node index
356
+ * @returns the code, or 0 when no node row declared the node
357
+ */
358
+ codeAt(index: number): number {
359
+ return index < this.codes.length ? this.codes[index] : 0;
360
+ }
343
361
  }
344
362
 
345
363
  /**
@@ -675,7 +693,19 @@ class Neo4jImportSession {
675
693
  weightCell = fields.findIndex((field) => field.kind === "PROPERTY" && field.name === weightFrom);
676
694
  }
677
695
  const properties = this.declareProperties("edge", fields, line, weightCell);
678
- return { kind: "relationship", width: fields.length, startCell, endCell, typeCell, weightCell, properties };
696
+ const { space: startSpace } = fields[startCell];
697
+ const { space: endSpace } = fields[endCell];
698
+ return {
699
+ kind: "relationship",
700
+ width: fields.length,
701
+ startCell,
702
+ endCell,
703
+ startSpaceCode: startSpace === null ? 0 : this.registry.codeOf(startSpace),
704
+ endSpaceCode: endSpace === null ? 0 : this.registry.codeOf(endSpace),
705
+ typeCell,
706
+ weightCell,
707
+ properties,
708
+ };
679
709
  }
680
710
 
681
711
  /**
@@ -944,6 +974,22 @@ class Neo4jImportSession {
944
974
  report.counts.skippedEdges++;
945
975
  return;
946
976
  }
977
+ let wrongSpace: string | null = null;
978
+ if (this.wrongSpace(source, section.startSpaceCode)) {
979
+ wrongSpace = startText;
980
+ } else if (this.wrongSpace(target, section.endSpaceCode)) {
981
+ wrongSpace = endText;
982
+ }
983
+ if (wrongSpace !== null) {
984
+ report.error(
985
+ "missing-value",
986
+ ENDPOINT_SPACE_CODE,
987
+ `endpoint ${wrongSpace} is not a node of its declared id space (a node of another id space has that id); the row is skipped`,
988
+ { line, element },
989
+ );
990
+ report.counts.skippedEdges++;
991
+ return;
992
+ }
947
993
  let weight: number | undefined;
948
994
  if (section.weightCell >= 0) {
949
995
  try {
@@ -977,6 +1023,26 @@ class Neo4jImportSession {
977
1023
  report.counts.edges++;
978
1024
  }
979
1025
 
1026
+ /**
1027
+ * Whether an endpoint id belongs to a node a node row declared in another id space than the
1028
+ * endpoint's header declares. An endpoint without a declared space, or a node no row declared
1029
+ * yet, is looked up by id alone.
1030
+ * @param id - the endpoint id
1031
+ * @param spaceCode - the endpoint's space code, 0 for none
1032
+ * @returns true when the endpoint resolves to a node of another space
1033
+ */
1034
+ private wrongSpace(id: NodeId, spaceCode: number): boolean {
1035
+ if (spaceCode === 0) {
1036
+ return false;
1037
+ }
1038
+ const index = this.sink.indexOf(id);
1039
+ if (index < 0) {
1040
+ return false;
1041
+ }
1042
+ const declared = this.registry.codeAt(index);
1043
+ return declared !== 0 && declared !== spaceCode;
1044
+ }
1045
+
980
1046
  /**
981
1047
  * Coerce an id cell, reporting a merge under `ids: "number"` and an invalid id as an error.
982
1048
  * @param text - the cell
@@ -32,6 +32,7 @@ import {
32
32
  import {
33
33
  COLUMN_COUNT_CODE,
34
34
  DUPLICATE_NODE_CODE,
35
+ ENDPOINT_SPACE_CODE,
35
36
  HEADER_CODE,
36
37
  HEADER_OPTION_CODE,
37
38
  ID_MERGED_CODE,
@@ -75,6 +76,8 @@ export const NEO4J_ISSUE = Object.freeze({
75
76
  DUPLICATE_NODE: DUPLICATE_NODE_CODE,
76
77
  /** A node id declared in two id spaces. */
77
78
  ID_SPACE_COLLISION: ID_SPACE_COLLISION_CODE,
79
+ /** A relationship endpoint whose id belongs to a node of another id space; the row is skipped. */
80
+ ENDPOINT_SPACE: ENDPOINT_SPACE_CODE,
78
81
  /** Two id texts merged into one number under ids "number". */
79
82
  ID_MERGED: ID_MERGED_CODE,
80
83
  /** A header brace option the importer does not apply. */