@xml-auditfile/auditfile 0.0.0-stage → 0.0.2-alpha.3
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/LICENSE +201 -0
- package/README.md +183 -2
- package/package.json +62 -4
- package/src/auditfile.js +186 -0
- package/src/definitions/dialect.js +40 -0
- package/src/definitions/element-definition.js +27 -0
- package/src/definitions/localized-text.js +41 -0
- package/src/definitions/manifest.js +160 -0
- package/src/definitions/names.js +42 -0
- package/src/definitions/resources.js +29 -0
- package/src/definitions/view-definition.js +160 -0
- package/src/diagnostics/collector.js +139 -0
- package/src/diagnostics/diagnostic.js +53 -0
- package/src/diagnostics/report.js +56 -0
- package/src/generated/spec.js +74 -0
- package/src/index.js +20 -0
- package/src/internal/composite-key.js +2 -0
- package/src/internal/decimal.js +193 -0
- package/src/internal/encoding.js +118 -0
- package/src/internal/json.js +106 -0
- package/src/internal/sha256.js +147 -0
- package/src/internal/streams.js +232 -0
- package/src/internal/xml-reader.js +247 -0
- package/src/memory/memory-sink.js +68 -0
- package/src/reading/mapping-plan.js +91 -0
- package/src/reading/read-options.js +136 -0
- package/src/reading/read-session.js +446 -0
- package/src/reading/row-loader.js +362 -0
- package/src/reading/row.js +123 -0
- package/src/reading/source.js +146 -0
- package/src/reading/value-converter.js +136 -0
- package/src/schema/schema.js +256 -0
- package/src/sinks/sink.js +53 -0
- package/src/validation/reference-checker.js +146 -0
- package/src/validation/xaf-rules.js +167 -0
- package/src/versioning/version-resolver.js +119 -0
- package/src/versioning/version.js +217 -0
- package/src/views/view-result.js +20 -0
- package/src/views/view-runner.js +449 -0
- package/src/xsd/datatypes.js +141 -0
- package/src/xsd/regex.js +121 -0
- package/src/xsd/schema-set.js +555 -0
- package/src/xsd/validator.js +484 -0
- package/types/auditfile.d.ts +57 -0
- package/types/definitions/dialect.d.ts +27 -0
- package/types/definitions/element-definition.d.ts +16 -0
- package/types/definitions/localized-text.d.ts +25 -0
- package/types/definitions/manifest.d.ts +118 -0
- package/types/definitions/names.d.ts +25 -0
- package/types/definitions/resources.d.ts +9 -0
- package/types/definitions/view-definition.d.ts +85 -0
- package/types/diagnostics/collector.d.ts +38 -0
- package/types/diagnostics/diagnostic.d.ts +37 -0
- package/types/diagnostics/report.d.ts +32 -0
- package/types/generated/spec.d.ts +6 -0
- package/types/index.d.ts +18 -0
- package/types/internal/composite-key.d.ts +2 -0
- package/types/internal/decimal.d.ts +55 -0
- package/types/internal/encoding.d.ts +43 -0
- package/types/internal/json.d.ts +17 -0
- package/types/internal/sha256.d.ts +17 -0
- package/types/internal/streams.d.ts +35 -0
- package/types/internal/xml-reader.d.ts +50 -0
- package/types/memory/memory-sink.d.ts +29 -0
- package/types/reading/mapping-plan.d.ts +45 -0
- package/types/reading/read-options.d.ts +69 -0
- package/types/reading/read-session.d.ts +28 -0
- package/types/reading/row-loader.d.ts +18 -0
- package/types/reading/row.d.ts +53 -0
- package/types/reading/source.d.ts +73 -0
- package/types/reading/value-converter.d.ts +15 -0
- package/types/schema/schema.d.ts +116 -0
- package/types/sinks/sink.d.ts +59 -0
- package/types/validation/reference-checker.d.ts +11 -0
- package/types/validation/xaf-rules.d.ts +13 -0
- package/types/versioning/version-resolver.d.ts +25 -0
- package/types/versioning/version.d.ts +71 -0
- package/types/views/view-result.d.ts +13 -0
- package/types/views/view-runner.d.ts +1 -0
- package/types/xsd/datatypes.d.ts +40 -0
- package/types/xsd/regex.d.ts +6 -0
- package/types/xsd/schema-set.d.ts +20 -0
- package/types/xsd/validator.d.ts +48 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { ColumnType } from '../definitions/manifest.js';
|
|
2
|
+
import { Decimal } from '../internal/decimal.js';
|
|
3
|
+
|
|
4
|
+
/** Converts XML text to typed values, always in XSD notation and independent of locale. */
|
|
5
|
+
|
|
6
|
+
const INTEGER = /^[+-]?\d+$/;
|
|
7
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
8
|
+
const TIME_ZONE = /^(?:|Z|[+-]\d{2}:\d{2})$/;
|
|
9
|
+
// Only the XSD notation: a date-time like 03/01/2017 10:00:00 or with a space instead of T is refused, never guessed.
|
|
10
|
+
const DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?(Z|[+-]\d{2}:\d{2})?$/;
|
|
11
|
+
// hh:mm:ss with an optional fraction (dropped) and time zone (ignored), as for a date.
|
|
12
|
+
const TIME = /^(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
13
|
+
|
|
14
|
+
// The range of .NET long, so every implementation accepts the same integers; JavaScript numbers must also be safe.
|
|
15
|
+
const MIN_INTEGER = -(2n ** 63n);
|
|
16
|
+
const MAX_INTEGER = 2n ** 63n - 1n;
|
|
17
|
+
|
|
18
|
+
/** A converted value, or failure. */
|
|
19
|
+
const FAILED = Object.freeze({ ok: false, value: null });
|
|
20
|
+
|
|
21
|
+
function ok(value) {
|
|
22
|
+
return { ok: true, value };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A UTC date, or null when the parts do not form a real date. */
|
|
26
|
+
function utcDate(year, month, day, hour = 0, minute = 0, second = 0, millisecond = 0) {
|
|
27
|
+
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
|
|
28
|
+
date.setUTCFullYear(year);
|
|
29
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
30
|
+
&& date.getUTCHours() === hour && date.getUTCMinutes() === minute && date.getUTCSeconds() === second
|
|
31
|
+
? date
|
|
32
|
+
: null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Empty text becomes null. Returns `{ ok: false }` when the text is not valid for the type.
|
|
37
|
+
* @param {string} raw
|
|
38
|
+
* @param {string} type One of {@link ColumnType}.
|
|
39
|
+
* @returns {{ ok: boolean, value: unknown }}
|
|
40
|
+
*/
|
|
41
|
+
export function tryConvert(raw, type) {
|
|
42
|
+
if (type === ColumnType.String) {
|
|
43
|
+
return ok(raw);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const text = raw.trim();
|
|
47
|
+
if (text.length === 0) {
|
|
48
|
+
return ok(null);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch (type) {
|
|
52
|
+
case ColumnType.Decimal: {
|
|
53
|
+
const value = Decimal.parse(text);
|
|
54
|
+
return value === null ? FAILED : ok(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
case ColumnType.Integer: {
|
|
58
|
+
if (!INTEGER.test(text) || text.length > 21) {
|
|
59
|
+
return FAILED;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const value = BigInt(text);
|
|
63
|
+
return value < MIN_INTEGER || value > MAX_INTEGER || value < BigInt(Number.MIN_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)
|
|
64
|
+
? FAILED
|
|
65
|
+
: ok(Number(value));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case ColumnType.Date: {
|
|
69
|
+
// The date as written; an optional time zone (Z, +hh:mm, -hh:mm) is ignored, never converted.
|
|
70
|
+
const match = DATE.exec(text.slice(0, 10));
|
|
71
|
+
if (match === null || !TIME_ZONE.test(text.slice(10))) {
|
|
72
|
+
return FAILED;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const date = utcDate(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
76
|
+
return date === null ? FAILED : ok(date);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case ColumnType.DateTime: {
|
|
80
|
+
const match = DATE_TIME.exec(text);
|
|
81
|
+
if (match === null) {
|
|
82
|
+
return FAILED;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [, year, month, day, hour, minute, second, fraction = '', zone] = match;
|
|
86
|
+
const local = utcDate(Number(year), Number(month), Number(day), Number(hour), Number(minute), Number(second),
|
|
87
|
+
Math.floor(Number(`0.${fraction || '0'}`) * 1000));
|
|
88
|
+
if (local === null) {
|
|
89
|
+
return FAILED;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (zone === undefined || zone === 'Z') {
|
|
93
|
+
return ok(local);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const offset = (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6))) * (zone[0] === '-' ? -1 : 1);
|
|
97
|
+
return ok(new Date(local.getTime() - offset * 60000));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case ColumnType.Time: {
|
|
101
|
+
const match = TIME.exec(text);
|
|
102
|
+
if (match === null || Number(match[1]) > 23 || Number(match[2]) > 59 || Number(match[3]) > 59) {
|
|
103
|
+
return FAILED;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return ok(`${match[1]}:${match[2]}:${match[3]}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case ColumnType.Boolean:
|
|
110
|
+
if (text === 'true' || text === '1') {
|
|
111
|
+
return ok(true);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return text === 'false' || text === '0' ? ok(false) : FAILED;
|
|
115
|
+
|
|
116
|
+
default:
|
|
117
|
+
return ok(raw);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Text form of a value for keys and messages: dates as yyyy-mm-dd, decimals as written.
|
|
123
|
+
* @param {unknown} value
|
|
124
|
+
*/
|
|
125
|
+
export function toText(value) {
|
|
126
|
+
if (value === null || value === undefined) {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (value instanceof Date) {
|
|
131
|
+
const iso = value.toISOString();
|
|
132
|
+
return iso.endsWith('T00:00:00.000Z') ? iso.slice(0, 10) : iso;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return String(value);
|
|
136
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { ManifestKey, ManifestReference, parseColumnType } from '../definitions/manifest.js';
|
|
2
|
+
import { bool, int, objects, optionalInt, optionalString, parseObject, string, strings } from '../internal/json.js';
|
|
3
|
+
import { AuditfileVersion } from '../versioning/version.js';
|
|
4
|
+
|
|
5
|
+
/** How the version of an import was determined. */
|
|
6
|
+
export const VersionSource = Object.freeze({
|
|
7
|
+
/** The file uses the official namespace. */
|
|
8
|
+
Official: 'official',
|
|
9
|
+
/** The file uses a known alias namespace. */
|
|
10
|
+
Alias: 'alias',
|
|
11
|
+
/** The caller forced the version. */
|
|
12
|
+
Forced: 'forced',
|
|
13
|
+
/** The resolver derived the version from the namespace. */
|
|
14
|
+
Resolved: 'resolved',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const SOURCES = new Set(Object.values(VersionSource));
|
|
18
|
+
|
|
19
|
+
/** Format version written by toJson; fromJson refuses any other. */
|
|
20
|
+
const FORMAT_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The schema of one import: tables, columns, types, keys, references, row counts and origin.
|
|
24
|
+
* This is the contract everything after the reader builds on, regardless of where the data is stored.
|
|
25
|
+
* Converts to and from JSON without loss (definitions are re-attached from the version when known).
|
|
26
|
+
*/
|
|
27
|
+
export class AuditfileSchema {
|
|
28
|
+
#byName;
|
|
29
|
+
|
|
30
|
+
/** @internal */
|
|
31
|
+
constructor(versionKey, fileNamespace, source, dialect, hasRowCounts, tables) {
|
|
32
|
+
/** Version key, e.g. "xaf/3.2". */
|
|
33
|
+
this.versionKey = versionKey;
|
|
34
|
+
/** @type {AuditfileVersion | null} The version, or null when this reader does not know it (schema loaded from JSON). */
|
|
35
|
+
this.version = AuditfileVersion.find(versionKey);
|
|
36
|
+
/** Namespace as found in the file. */
|
|
37
|
+
this.namespace = fileNamespace;
|
|
38
|
+
/** How the version was determined, one of {@link VersionSource}. */
|
|
39
|
+
this.source = source;
|
|
40
|
+
/** Name of the applied dialect, or null. */
|
|
41
|
+
this.dialect = dialect;
|
|
42
|
+
/** True when the row count is known for every table. */
|
|
43
|
+
this.hasRowCounts = hasRowCounts;
|
|
44
|
+
/** @type {readonly SchemaTable[]} All tables in document order. */
|
|
45
|
+
this.tables = Object.freeze([...tables]);
|
|
46
|
+
this.#byName = new Map(this.tables.map((t) => [t.name, t]));
|
|
47
|
+
Object.freeze(this);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Finds a table by name, or returns null.
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @returns {SchemaTable | null}
|
|
54
|
+
*/
|
|
55
|
+
findTable(name) {
|
|
56
|
+
return this.#byName.get(name) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns a table by name.
|
|
61
|
+
* @param {string} name
|
|
62
|
+
* @throws {RangeError} The table does not exist.
|
|
63
|
+
*/
|
|
64
|
+
table(name) {
|
|
65
|
+
const table = this.findTable(name);
|
|
66
|
+
if (table === null) {
|
|
67
|
+
throw new RangeError(`Table '${name}' does not exist.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return table;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Serializes the schema to compact JSON, e.g. for storage in a database. */
|
|
74
|
+
toJson() {
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
formatVersion: FORMAT_VERSION,
|
|
77
|
+
version: this.versionKey,
|
|
78
|
+
namespace: this.namespace,
|
|
79
|
+
source: this.source,
|
|
80
|
+
dialect: this.dialect,
|
|
81
|
+
hasRowCounts: this.hasRowCounts,
|
|
82
|
+
tables: this.tables.map((table) => ({
|
|
83
|
+
name: table.name,
|
|
84
|
+
xmlName: table.xmlName,
|
|
85
|
+
path: table.path,
|
|
86
|
+
parent: table.parent,
|
|
87
|
+
minOccurs: table.minOccurs,
|
|
88
|
+
maxOccurs: table.maxOccurs,
|
|
89
|
+
required: table.isRequired,
|
|
90
|
+
expected: table.isExpected,
|
|
91
|
+
rowCount: table.rowCount,
|
|
92
|
+
keys: table.keys.map((key) => ({ name: key.name, columns: key.columns })),
|
|
93
|
+
references: table.references.map((reference) => ({
|
|
94
|
+
name: reference.name, columns: reference.columns, table: reference.table, key: reference.key,
|
|
95
|
+
})),
|
|
96
|
+
columns: table.columns.map((column) => ({
|
|
97
|
+
name: column.name,
|
|
98
|
+
xmlName: column.xmlName,
|
|
99
|
+
path: column.path,
|
|
100
|
+
kind: column.kind,
|
|
101
|
+
type: column.type,
|
|
102
|
+
xsdType: column.xsdType,
|
|
103
|
+
minOccurs: column.minOccurs,
|
|
104
|
+
})),
|
|
105
|
+
})),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reads a schema written by {@link AuditfileSchema#toJson}.
|
|
111
|
+
* @param {string} json
|
|
112
|
+
* @throws {SyntaxError} The JSON is not a valid schema, or uses a format version, column type or version source this reader does not know.
|
|
113
|
+
*/
|
|
114
|
+
static fromJson(json) {
|
|
115
|
+
if (typeof json !== 'string') {
|
|
116
|
+
throw new TypeError('json must be a string.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const root = parseObject(json, 'Schema JSON');
|
|
120
|
+
if (root.formatVersion !== FORMAT_VERSION) {
|
|
121
|
+
throw new SyntaxError(`Schema JSON format version ${root.formatVersion ?? '(missing)'} is not supported; this reader reads format version ${FORMAT_VERSION}.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const versionKey = string(root, 'version');
|
|
125
|
+
const definitions = AuditfileVersion.find(versionKey)?.definitions ?? null;
|
|
126
|
+
const lookup = (key) => definitions?.get(key) ?? null;
|
|
127
|
+
|
|
128
|
+
const tables = objects(root, 'tables').map((t, index) => {
|
|
129
|
+
const name = string(t, 'name');
|
|
130
|
+
const columns = objects(t, 'columns').map((c, i) => new SchemaColumn(
|
|
131
|
+
string(c, 'name'), string(c, 'xmlName'), string(c, 'path'), string(c, 'kind'),
|
|
132
|
+
parseColumnType(string(c, 'type')), string(c, 'xsdType'), int(c, 'minOccurs'), i,
|
|
133
|
+
lookup(`${name}.${string(c, 'name')}`)));
|
|
134
|
+
|
|
135
|
+
return new SchemaTable(
|
|
136
|
+
name, string(t, 'xmlName'), string(t, 'path'), optionalString(t, 'parent'),
|
|
137
|
+
int(t, 'minOccurs'), optionalInt(t, 'maxOccurs'), bool(t, 'required'), bool(t, 'expected'),
|
|
138
|
+
optionalInt(t, 'rowCount'), index, columns,
|
|
139
|
+
objects(t, 'keys').map((k) => new ManifestKey(string(k, 'name'), strings(k, 'columns'))),
|
|
140
|
+
objects(t, 'references').map((r) => new ManifestReference(string(r, 'name'), strings(r, 'columns'), string(r, 'table'), string(r, 'key'))),
|
|
141
|
+
lookup(name));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const source = string(root, 'source');
|
|
145
|
+
if (!SOURCES.has(source)) {
|
|
146
|
+
throw new SyntaxError(`Unknown version source '${source}' in schema JSON.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return new AuditfileSchema(versionKey, string(root, 'namespace'), source,
|
|
150
|
+
optionalString(root, 'dialect'), bool(root, 'hasRowCounts'), tables);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @internal
|
|
155
|
+
* @param {AuditfileVersion} version
|
|
156
|
+
* @param {Map<string, number> | null} rowCounts
|
|
157
|
+
*/
|
|
158
|
+
static create(version, fileNamespace, source, dialect, rowCounts) {
|
|
159
|
+
const manifest = version.manifest;
|
|
160
|
+
const definitions = version.definitions;
|
|
161
|
+
const required = new Set(manifest.requiredTables);
|
|
162
|
+
const expected = new Set(manifest.expectedTables);
|
|
163
|
+
|
|
164
|
+
const tables = manifest.tables.map((t) => new SchemaTable(
|
|
165
|
+
t.name, t.xmlName, t.path, t.parent, t.minOccurs, t.maxOccurs, required.has(t.name), expected.has(t.name),
|
|
166
|
+
rowCounts === null ? null : rowCounts.get(t.name) ?? 0,
|
|
167
|
+
t.index,
|
|
168
|
+
t.columns.map((c) => new SchemaColumn(c.name, c.xmlName, c.path, c.kind, c.type, c.xsdType, c.minOccurs, c.index,
|
|
169
|
+
definitions.get(`${t.name}.${c.name}`) ?? null)),
|
|
170
|
+
t.keys, t.references, definitions.get(t.name) ?? null));
|
|
171
|
+
|
|
172
|
+
return new AuditfileSchema(version.key, fileNamespace, source, dialect, rowCounts !== null, tables);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A table of an import. */
|
|
177
|
+
export class SchemaTable {
|
|
178
|
+
#byName;
|
|
179
|
+
|
|
180
|
+
/** @internal */
|
|
181
|
+
constructor(name, xmlName, path, parent, minOccurs, maxOccurs, isRequired, isExpected, rowCount, index, columns, keys, references, definition) {
|
|
182
|
+
/** Table name, e.g. "tr_line". */
|
|
183
|
+
this.name = name;
|
|
184
|
+
/** Element name, e.g. "trLine". */
|
|
185
|
+
this.xmlName = xmlName;
|
|
186
|
+
/** Full XML path. */
|
|
187
|
+
this.path = path;
|
|
188
|
+
/** Parent table name, or null for the root. */
|
|
189
|
+
this.parent = parent;
|
|
190
|
+
/** Minimum occurrences relative to the XML parent element. */
|
|
191
|
+
this.minOccurs = minOccurs;
|
|
192
|
+
/** Maximum occurrences relative to the XML parent element; null means unbounded. */
|
|
193
|
+
this.maxOccurs = maxOccurs;
|
|
194
|
+
/** Required by the standard. */
|
|
195
|
+
this.isRequired = isRequired;
|
|
196
|
+
/** Expected in practice. */
|
|
197
|
+
this.isExpected = isExpected;
|
|
198
|
+
/** Number of rows, when known from the scan phase; otherwise null. */
|
|
199
|
+
this.rowCount = rowCount;
|
|
200
|
+
/** Position in the schema. */
|
|
201
|
+
this.index = index;
|
|
202
|
+
/** @type {readonly SchemaColumn[]} Columns; {@link AuditfileRow#values} uses the same order. */
|
|
203
|
+
this.columns = Object.freeze([...columns]);
|
|
204
|
+
/** @type {readonly ManifestKey[]} Natural keys. */
|
|
205
|
+
this.keys = Object.freeze([...keys]);
|
|
206
|
+
/** @type {readonly ManifestReference[]} References to keys of other tables. */
|
|
207
|
+
this.references = Object.freeze([...references]);
|
|
208
|
+
/** @type {import('../definitions/element-definition.js').ElementDefinition | null} Documentation, when available. */
|
|
209
|
+
this.definition = definition;
|
|
210
|
+
this.#byName = new Map(this.columns.map((c) => [c.name, c]));
|
|
211
|
+
Object.freeze(this);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Finds a column by name, or returns null.
|
|
216
|
+
* @param {string} name
|
|
217
|
+
* @returns {SchemaColumn | null}
|
|
218
|
+
*/
|
|
219
|
+
findColumn(name) {
|
|
220
|
+
return this.#byName.get(name) ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
toString() {
|
|
224
|
+
return this.name;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A column of an import table. */
|
|
229
|
+
export class SchemaColumn {
|
|
230
|
+
/** @internal */
|
|
231
|
+
constructor(name, xmlName, path, kind, type, xsdType, minOccurs, index, definition) {
|
|
232
|
+
/** Column name. */
|
|
233
|
+
this.name = name;
|
|
234
|
+
/** Element or attribute name. */
|
|
235
|
+
this.xmlName = xmlName;
|
|
236
|
+
/** Path relative to the table element. */
|
|
237
|
+
this.path = path;
|
|
238
|
+
/** "element" or "attribute". */
|
|
239
|
+
this.kind = kind;
|
|
240
|
+
/** Logical type, one of {@link ColumnType}; determines the JavaScript type of the value. */
|
|
241
|
+
this.type = type;
|
|
242
|
+
/** XSD type name. */
|
|
243
|
+
this.xsdType = xsdType;
|
|
244
|
+
/** Minimum occurrences relative to the XML parent element. */
|
|
245
|
+
this.minOccurs = minOccurs;
|
|
246
|
+
/** Position in {@link AuditfileRow#values}. */
|
|
247
|
+
this.index = index;
|
|
248
|
+
/** @type {import('../definitions/element-definition.js').ElementDefinition | null} Documentation, when available. */
|
|
249
|
+
this.definition = definition;
|
|
250
|
+
Object.freeze(this);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
toString() {
|
|
254
|
+
return this.name;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** What a sink needs from the reader (bit flags). */
|
|
2
|
+
export const SinkCapabilities = Object.freeze({
|
|
3
|
+
/** Nothing special. */
|
|
4
|
+
None: 0,
|
|
5
|
+
/** The sink wants row counts before the first row (e.g. to pre-allocate); the reader scans first. */
|
|
6
|
+
NeedsRowCounts: 1,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Receives an import: first the schema, then rows per table in batches, then completion or abort.
|
|
11
|
+
* All validation, merging and reporting happens before the sink, so a custom sink gets the same result as a built-in one.
|
|
12
|
+
* Every method may return a promise.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {object} Sink
|
|
15
|
+
* @property {number} [capabilities] What the sink needs, as {@link SinkCapabilities} flags.
|
|
16
|
+
* @property {(context: ImportContext) => void | Promise<void>} begin Start of an import. The schema contains row counts when the reader scanned first.
|
|
17
|
+
* @property {(table: import('../schema/schema.js').SchemaTable, rows: readonly import('../reading/row.js').AuditfileRow[]) => void | Promise<void>} write
|
|
18
|
+
* A batch of rows of one table. Child rows can arrive before their parent row; ids are already assigned.
|
|
19
|
+
* @property {(report: import('../diagnostics/report.js').AuditfileReport) => void | Promise<void>} complete The import is complete; commit.
|
|
20
|
+
* @property {(report: import('../diagnostics/report.js').AuditfileReport) => void | Promise<void>} abort The import failed; discard everything received since begin.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Information at the start of an import. */
|
|
24
|
+
export class ImportContext {
|
|
25
|
+
/** @internal */
|
|
26
|
+
constructor(schema, parts) {
|
|
27
|
+
/** @type {import('../schema/schema.js').AuditfileSchema} The import schema. */
|
|
28
|
+
this.schema = schema;
|
|
29
|
+
/** @type {readonly import('../reading/source.js').SourcePart[]} The source parts. */
|
|
30
|
+
this.parts = parts;
|
|
31
|
+
Object.freeze(this);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Discards everything; used for validation. */
|
|
36
|
+
export const NULL_SINK = Object.freeze({
|
|
37
|
+
capabilities: SinkCapabilities.None,
|
|
38
|
+
begin() {},
|
|
39
|
+
write() {},
|
|
40
|
+
complete() {},
|
|
41
|
+
abort() {},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** @param {unknown} sink */
|
|
45
|
+
export function checkSink(sink) {
|
|
46
|
+
for (const method of ['begin', 'write', 'complete', 'abort']) {
|
|
47
|
+
if (typeof sink?.[method] !== 'function') {
|
|
48
|
+
throw new TypeError(`sink must have a ${method} method.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return sink;
|
|
53
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Location } from '../diagnostics/collector.js';
|
|
2
|
+
import { SEPARATOR } from '../internal/composite-key.js';
|
|
3
|
+
import { toText } from '../reading/value-converter.js';
|
|
4
|
+
|
|
5
|
+
const MAX_PENDING_PER_REFERENCE = 100_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks keys (unique) and references (existing) from the schema.
|
|
9
|
+
* Only key values and unresolved reference values are kept in memory; references are resolved at the end,
|
|
10
|
+
* so a reference to a key that appears later (e.g. in a later part) is still found.
|
|
11
|
+
*/
|
|
12
|
+
export class ReferenceChecker {
|
|
13
|
+
#diagnostics;
|
|
14
|
+
/** @type {Map<string, KeyState>} */
|
|
15
|
+
#keysByName = new Map();
|
|
16
|
+
/** @type {Map<object, KeyState[]>} */
|
|
17
|
+
#keysByTable = new Map();
|
|
18
|
+
/** @type {Map<object, ReferenceState[]>} */
|
|
19
|
+
#referencesByTable = new Map();
|
|
20
|
+
|
|
21
|
+
constructor(schema, diagnostics) {
|
|
22
|
+
this.#diagnostics = diagnostics;
|
|
23
|
+
|
|
24
|
+
for (const table of schema.tables) {
|
|
25
|
+
for (const key of table.keys) {
|
|
26
|
+
const state = new KeyState(table, indexes(table, key.columns));
|
|
27
|
+
this.#keysByName.set(key.name, state);
|
|
28
|
+
add(this.#keysByTable, table, state);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const table of schema.tables) {
|
|
33
|
+
for (const reference of table.references) {
|
|
34
|
+
const key = this.#keysByName.get(reference.key);
|
|
35
|
+
if (key !== undefined) {
|
|
36
|
+
add(this.#referencesByTable, table, new ReferenceState(table, reference.columns.join(', '), indexes(table, reference.columns), key));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onRow(row) {
|
|
43
|
+
for (const key of this.#keysByTable.get(row.table) ?? []) {
|
|
44
|
+
const value = composite(row, key.columns);
|
|
45
|
+
if (value !== null) {
|
|
46
|
+
if (key.values.has(value)) {
|
|
47
|
+
this.#diagnostics.add('XAF-R004', new Location(row.part, null, null, row.table.path), { value: display(value), table: row.table.name });
|
|
48
|
+
} else {
|
|
49
|
+
key.values.add(value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const reference of this.#referencesByTable.get(row.table) ?? []) {
|
|
55
|
+
const value = composite(row, reference.columns);
|
|
56
|
+
if (value === null || reference.key.values.has(value) || reference.pending.has(value)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (reference.pending.size < MAX_PENDING_PER_REFERENCE) {
|
|
61
|
+
reference.pending.add(value);
|
|
62
|
+
} else if (!reference.overflowed) {
|
|
63
|
+
// From here on new values are not checked; say so once, so a clean result is not mistaken for a complete one.
|
|
64
|
+
reference.overflowed = true;
|
|
65
|
+
this.#diagnostics.add('XAF-R017', new Location(row.part, null, null, row.table.path), {
|
|
66
|
+
table: row.table.name, column: reference.columnNames, target: reference.key.table.name, max: MAX_PENDING_PER_REFERENCE,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onEnd() {
|
|
73
|
+
for (const references of this.#referencesByTable.values()) {
|
|
74
|
+
for (const reference of references) {
|
|
75
|
+
const missing = [...reference.pending].filter((v) => !reference.key.values.has(v)).sort(ordinal);
|
|
76
|
+
for (const value of missing) {
|
|
77
|
+
this.#diagnostics.add('XAF-R003', new Location(null, null, null, reference.table.path), {
|
|
78
|
+
table: reference.table.name, column: reference.columnNames, value: display(value), target: reference.key.table.name,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Ordinal string order (UTF-16 code units), as everywhere in this reader. */
|
|
87
|
+
function ordinal(a, b) {
|
|
88
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function composite(row, columns) {
|
|
92
|
+
const parts = [];
|
|
93
|
+
for (const column of columns) {
|
|
94
|
+
const value = row.values[column];
|
|
95
|
+
if (value === null || value === undefined) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
parts.push(toText(value));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parts.join(SEPARATOR);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function display(value) {
|
|
106
|
+
return value.replaceAll(SEPARATOR, '/');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function indexes(table, columns) {
|
|
110
|
+
return columns.map((name) => {
|
|
111
|
+
const column = table.findColumn(name);
|
|
112
|
+
if (column === null) {
|
|
113
|
+
throw new Error(`Key column ${name} not in ${table.name}.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return column.index;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function add(map, table, item) {
|
|
121
|
+
const list = map.get(table) ?? [];
|
|
122
|
+
list.push(item);
|
|
123
|
+
map.set(table, list);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class KeyState {
|
|
127
|
+
values = new Set();
|
|
128
|
+
|
|
129
|
+
constructor(table, columns) {
|
|
130
|
+
this.table = table;
|
|
131
|
+
this.columns = columns;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class ReferenceState {
|
|
136
|
+
pending = new Set();
|
|
137
|
+
/** True once the pending limit was reached and XAF-R017 was reported. */
|
|
138
|
+
overflowed = false;
|
|
139
|
+
|
|
140
|
+
constructor(table, columnNames, columns, key) {
|
|
141
|
+
this.table = table;
|
|
142
|
+
this.columnNames = columnNames;
|
|
143
|
+
this.columns = columns;
|
|
144
|
+
this.key = key;
|
|
145
|
+
}
|
|
146
|
+
}
|