meadow-integration 1.0.43 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/source/Meadow-Integration-Engine.js +13 -0
- package/source/Meadow-Service-Integration-Adapter.js +30 -9
- package/source/services/guid/Meadow-Integration-GUIDComposer.js +131 -0
- package/source/services/guid/Meadow-Integration-GUIDStrategy.js +238 -0
- package/source/services/tabular/Service-TabularTransform.js +63 -1
- package/test/Meadow-Integration-GUIDComposer_test.js +162 -0
- package/test/Meadow-Integration-GUIDStrategyTransform_test.js +144 -0
- package/test/Meadow-Integration-GUIDStrategy_test.js +150 -0
- package/test/Meadow-Integration-GUIDTruncation_test.js +71 -0
package/package.json
CHANGED
|
@@ -24,6 +24,13 @@ const MeadowIntegrationTabularTransform = require('./services/tabular/Service-Ta
|
|
|
24
24
|
const MeadowIntegrationAdapter = require('./Meadow-Service-Integration-Adapter.js');
|
|
25
25
|
const MeadowGUIDMap = require('./Meadow-Service-Integration-GUIDMap.js');
|
|
26
26
|
|
|
27
|
+
// Opt-in context-aware GUID layer (pure, dependency-light): compose deterministic, length-safe GUIDs that
|
|
28
|
+
// embed parent context (e.g. `UI_C10_P01278_LI8675309`) + a compiler that turns a per-entity strategy
|
|
29
|
+
// config into the structured spec the TabularTransform stamps. Reusable by the CLI, the server endpoints,
|
|
30
|
+
// and the browser import wizard alike.
|
|
31
|
+
const MeadowIntegrationGUIDComposer = require('./services/guid/Meadow-Integration-GUIDComposer.js');
|
|
32
|
+
const MeadowIntegrationGUIDStrategy = require('./services/guid/Meadow-Integration-GUIDStrategy.js');
|
|
33
|
+
|
|
27
34
|
module.exports =
|
|
28
35
|
{
|
|
29
36
|
MeadowIntegrationTabularTransform,
|
|
@@ -31,4 +38,10 @@ module.exports =
|
|
|
31
38
|
MeadowGUIDMap,
|
|
32
39
|
// The per-entity adapter factory (caches on fable.servicesMap.IntegrationAdapter[Entity]).
|
|
33
40
|
getAdapter: MeadowIntegrationAdapter.getAdapter,
|
|
41
|
+
|
|
42
|
+
// Context-aware GUID composition + strategy compilation.
|
|
43
|
+
MeadowIntegrationGUIDComposer,
|
|
44
|
+
MeadowIntegrationGUIDStrategy,
|
|
45
|
+
composeGUID: MeadowIntegrationGUIDComposer.composeGUID,
|
|
46
|
+
compileGUIDStrategy: MeadowIntegrationGUIDStrategy.compile,
|
|
34
47
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
2
2
|
const libGUIDMap = require('./Meadow-Service-Integration-GUIDMap.js');
|
|
3
|
+
const libGUIDComposer = require('./services/guid/Meadow-Integration-GUIDComposer.js');
|
|
3
4
|
|
|
4
5
|
const defaultMeadowIntegrationAdapterOptions = (
|
|
5
6
|
{
|
|
@@ -27,6 +28,11 @@ const defaultMeadowIntegrationAdapterOptions = (
|
|
|
27
28
|
// When true, the prefix is truncated to fit while preserving the full external GUID.
|
|
28
29
|
"AllowGUIDTruncation": false,
|
|
29
30
|
|
|
31
|
+
// How to shrink an over-length marshaled GUID when AllowGUIDTruncation is on:
|
|
32
|
+
// "substring" (default) — truncate the prefix, keep the external GUID whole (current behavior).
|
|
33
|
+
// "hash" — keep the prefix, deterministically hash the external GUID to fit (stable).
|
|
34
|
+
"GUIDTruncationStrategy": "substring",
|
|
35
|
+
|
|
30
36
|
// When true, only marshal fields that are present in the schema (no passthrough of unknown fields).
|
|
31
37
|
"SimpleMarshal": false,
|
|
32
38
|
|
|
@@ -105,6 +111,7 @@ class MeadowIntegrationAdapter extends libFableServiceProviderBase
|
|
|
105
111
|
this.GUIDMaxLength = this.options.DefaultGUIDColumnSize;
|
|
106
112
|
}
|
|
107
113
|
this.AllowGUIDTruncation = this.options.AllowGUIDTruncation;
|
|
114
|
+
this.GUIDTruncationStrategy = this.options.GUIDTruncationStrategy || 'substring';
|
|
108
115
|
|
|
109
116
|
// Integration Adapter Controls
|
|
110
117
|
this._PerformUpserts = this.options.PerformUpserts;
|
|
@@ -214,19 +221,33 @@ class MeadowIntegrationAdapter extends libFableServiceProviderBase
|
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
// AllowGUIDTruncation is on — the external GUID is sacrosanct, so truncate the prefix instead.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (tmpAvailablePrefixLength <= 0)
|
|
224
|
+
if (this.GUIDTruncationStrategy === 'hash')
|
|
220
225
|
{
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
226
|
+
// Hash strategy: keep the prefix; deterministically hash the external GUID into the remaining
|
|
227
|
+
// budget so the result stays stable across runs (idempotent) rather than losing prefix chars.
|
|
228
|
+
let tmpHashBudget = Math.max(8, this.GUIDMaxLength - this.GUIDPrefix.length);
|
|
229
|
+
let tmpHashedExternal = libGUIDComposer.hashSegment(pExternalGUID, tmpHashBudget);
|
|
230
|
+
tmpFullGUID = `${this.GUIDPrefix}${tmpHashedExternal}`;
|
|
231
|
+
if (tmpFullGUID.length > this.GUIDMaxLength) { tmpFullGUID = tmpFullGUID.substring(0, this.GUIDMaxLength); }
|
|
232
|
+
this.log.warn(`Generated GUID for [${this.Entity}] exceeded ${this.GUIDMaxLength} chars; external GUID hashed (GUIDTruncationStrategy=hash) to [${tmpHashedExternal}].`);
|
|
224
233
|
}
|
|
225
234
|
else
|
|
226
235
|
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
236
|
+
// Substring strategy (default): the external GUID is sacrosanct, so truncate the prefix instead.
|
|
237
|
+
let tmpAvailablePrefixLength = this.GUIDMaxLength - pExternalGUID.length;
|
|
238
|
+
|
|
239
|
+
if (tmpAvailablePrefixLength <= 0)
|
|
240
|
+
{
|
|
241
|
+
// External GUID alone meets or exceeds the limit; drop the prefix entirely.
|
|
242
|
+
this.log.warn(`External GUID [${pExternalGUID}] for [${this.Entity}] is ${pExternalGUID.length} characters which meets or exceeds the GUID max length of ${this.GUIDMaxLength}. Using external GUID with no prefix.`);
|
|
243
|
+
tmpFullGUID = pExternalGUID.substring(0, this.GUIDMaxLength);
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
{
|
|
247
|
+
let tmpTruncatedPrefix = this.GUIDPrefix.substring(0, tmpAvailablePrefixLength);
|
|
248
|
+
tmpFullGUID = `${tmpTruncatedPrefix}${pExternalGUID}`;
|
|
249
|
+
this.log.warn(`Generated GUID for [${this.Entity}] would be ${this.GUIDPrefix.length + pExternalGUID.length} characters (limit ${this.GUIDMaxLength}); prefix truncated from [${this.GUIDPrefix}] to [${tmpTruncatedPrefix}].`);
|
|
250
|
+
}
|
|
230
251
|
}
|
|
231
252
|
}
|
|
232
253
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Meadow-Integration-GUIDComposer — deterministic, length-safe composition of context-aware GUIDs.
|
|
2
|
+
//
|
|
3
|
+
// A "context-aware" GUID embeds its parents' identifiers so it is globally unique without a true UUID,
|
|
4
|
+
// e.g. a Line Item 8675309 on Contract 10 / Project 01278 composes to `UI_C10_P01278_LI8675309`. The
|
|
5
|
+
// hard part is staying inside the destination GUID column's width: when the composed string is too long
|
|
6
|
+
// this DETERMINISTICALLY hashes segments (interior context first, then the own segment, then the first)
|
|
7
|
+
// until it fits — `UI_C10_HASH_LI8675309` → `UI_C10_HASH_HASH` — so the same source row always yields the
|
|
8
|
+
// same GUID (idempotent upsert) even after shrinking.
|
|
9
|
+
//
|
|
10
|
+
// PURE + dependency-light on purpose: no node `crypto`, no fable, no DOM. It runs unchanged in the browser
|
|
11
|
+
// engine bundle (the Meadow-Integration-Engine boundary) and is trivially unit-testable in isolation.
|
|
12
|
+
|
|
13
|
+
const _DEFAULT_SEPARATOR = '_';
|
|
14
|
+
const _DEFAULT_HASH_LENGTH = 10;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deterministic 53-bit string hash (cyrb53) rendered as a fixed-length base36 token. Same input → same
|
|
18
|
+
* output in any JS runtime, forever — which is what makes a shrunk GUID still match on re-import.
|
|
19
|
+
* @param {string} pValue
|
|
20
|
+
* @param {number} [pLength] - max token length (default 10)
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function hashSegment(pValue, pLength)
|
|
24
|
+
{
|
|
25
|
+
const tmpLength = (typeof pLength === 'number' && pLength > 0) ? pLength : _DEFAULT_HASH_LENGTH;
|
|
26
|
+
const tmpString = String((pValue === null || pValue === undefined) ? '' : pValue);
|
|
27
|
+
let tmpH1 = 0xdeadbeef;
|
|
28
|
+
let tmpH2 = 0x41c6ce57;
|
|
29
|
+
for (let i = 0; i < tmpString.length; i++)
|
|
30
|
+
{
|
|
31
|
+
const tmpCharCode = tmpString.charCodeAt(i);
|
|
32
|
+
tmpH1 = Math.imul(tmpH1 ^ tmpCharCode, 2654435761);
|
|
33
|
+
tmpH2 = Math.imul(tmpH2 ^ tmpCharCode, 1597334677);
|
|
34
|
+
}
|
|
35
|
+
tmpH1 = Math.imul(tmpH1 ^ (tmpH1 >>> 16), 2246822507) ^ Math.imul(tmpH2 ^ (tmpH2 >>> 13), 3266489909);
|
|
36
|
+
tmpH2 = Math.imul(tmpH2 ^ (tmpH2 >>> 16), 2246822507) ^ Math.imul(tmpH1 ^ (tmpH1 >>> 13), 3266489909);
|
|
37
|
+
// 53-bit unsigned integer (safe-integer range) → base36 token.
|
|
38
|
+
const tmpHashNumber = 4294967296 * (2097151 & tmpH2) + (tmpH1 >>> 0);
|
|
39
|
+
const tmpToken = tmpHashNumber.toString(36);
|
|
40
|
+
return (tmpToken.length > tmpLength) ? tmpToken.slice(0, tmpLength) : tmpToken;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Render one segment as `<abbrev><value>` (e.g. `P01278`). */
|
|
44
|
+
function segmentString(pSegment)
|
|
45
|
+
{
|
|
46
|
+
const tmpSegment = pSegment || {};
|
|
47
|
+
const tmpAbbrev = (tmpSegment.abbrev === null || tmpSegment.abbrev === undefined) ? '' : String(tmpSegment.abbrev);
|
|
48
|
+
const tmpValue = (tmpSegment.value === null || tmpSegment.value === undefined) ? '' : String(tmpSegment.value);
|
|
49
|
+
return `${tmpAbbrev}${tmpValue}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Join the prefix (if any) + the segment strings with the separator. */
|
|
53
|
+
function _assemble(pPrefix, pSegmentStrings, pSeparator)
|
|
54
|
+
{
|
|
55
|
+
const tmpParts = pPrefix ? [ pPrefix ].concat(pSegmentStrings) : pSegmentStrings.slice();
|
|
56
|
+
return tmpParts.join(pSeparator);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The order to hash segments when shrinking: interior context segments first (left→right), then the own
|
|
61
|
+
* (last) segment, then the first context segment — so the prefix + first context + own stay readable
|
|
62
|
+
* longest. Deterministic, so the same overflow always shrinks the same way.
|
|
63
|
+
* @param {number} pCount
|
|
64
|
+
* @returns {Array<number>}
|
|
65
|
+
*/
|
|
66
|
+
function _shrinkOrder(pCount)
|
|
67
|
+
{
|
|
68
|
+
const tmpOrder = [];
|
|
69
|
+
for (let i = 1; i < pCount - 1; i++) { tmpOrder.push(i); }
|
|
70
|
+
if (pCount - 1 >= 1) { tmpOrder.push(pCount - 1); }
|
|
71
|
+
if (pCount >= 1) { tmpOrder.push(0); }
|
|
72
|
+
const tmpSeen = {};
|
|
73
|
+
return tmpOrder.filter((pIndex) => (tmpSeen[pIndex] ? false : (tmpSeen[pIndex] = true)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compose a deterministic, length-safe context-aware GUID.
|
|
78
|
+
* @param {object} pSpec
|
|
79
|
+
* @param {string} [pSpec.prefix] - the leading token (e.g. `UI`); omitted if falsy
|
|
80
|
+
* @param {Array<{abbrev:string, value:*}>} pSpec.segments - ordered context segments + the own segment last
|
|
81
|
+
* @param {string} [pSpec.separator] - default `_`
|
|
82
|
+
* @param {number} [pSpec.maxLength] - the destination GUID column width; <=0 means unbounded
|
|
83
|
+
* @param {number} [pSpec.hashLength] - per-segment hash token length (default 10)
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function composeGUID(pSpec)
|
|
87
|
+
{
|
|
88
|
+
const tmpSpec = pSpec || {};
|
|
89
|
+
const tmpPrefix = tmpSpec.prefix ? String(tmpSpec.prefix) : '';
|
|
90
|
+
const tmpSeparator = (typeof tmpSpec.separator === 'string') ? tmpSpec.separator : _DEFAULT_SEPARATOR;
|
|
91
|
+
const tmpMaxLength = (typeof tmpSpec.maxLength === 'number') ? tmpSpec.maxLength : 0;
|
|
92
|
+
const tmpHashLength = (typeof tmpSpec.hashLength === 'number' && tmpSpec.hashLength > 0) ? tmpSpec.hashLength : _DEFAULT_HASH_LENGTH;
|
|
93
|
+
const tmpSegments = Array.isArray(tmpSpec.segments) ? tmpSpec.segments : [];
|
|
94
|
+
|
|
95
|
+
const tmpSegmentStrings = tmpSegments.map(segmentString);
|
|
96
|
+
let tmpFull = _assemble(tmpPrefix, tmpSegmentStrings, tmpSeparator);
|
|
97
|
+
if (tmpMaxLength <= 0 || tmpFull.length <= tmpMaxLength)
|
|
98
|
+
{
|
|
99
|
+
return tmpFull;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Too long — hash segment VALUES (keeping each segment's abbreviation for readability) in shrink order
|
|
103
|
+
// until it fits. The hash is deterministic, so this stays stable across runs.
|
|
104
|
+
const tmpShrinkOrder = _shrinkOrder(tmpSegments.length);
|
|
105
|
+
for (let k = 0; k < tmpShrinkOrder.length; k++)
|
|
106
|
+
{
|
|
107
|
+
const tmpIndex = tmpShrinkOrder[k];
|
|
108
|
+
const tmpSegment = tmpSegments[tmpIndex] || {};
|
|
109
|
+
const tmpAbbrev = (tmpSegment.abbrev === null || tmpSegment.abbrev === undefined) ? '' : String(tmpSegment.abbrev);
|
|
110
|
+
tmpSegmentStrings[tmpIndex] = `${tmpAbbrev}${hashSegment(segmentString(tmpSegment), tmpHashLength)}`;
|
|
111
|
+
tmpFull = _assemble(tmpPrefix, tmpSegmentStrings, tmpSeparator);
|
|
112
|
+
if (tmpFull.length <= tmpMaxLength)
|
|
113
|
+
{
|
|
114
|
+
return tmpFull;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Last resort (even all-hashed is too long, or a tiny column): collapse the whole body after the prefix
|
|
119
|
+
// into a single hash sized to the remaining budget.
|
|
120
|
+
const tmpBody = _assemble(tmpPrefix, tmpSegments.map(segmentString), tmpSeparator);
|
|
121
|
+
const tmpPrefixCost = tmpPrefix ? (tmpPrefix.length + tmpSeparator.length) : 0;
|
|
122
|
+
const tmpBudget = Math.max(6, tmpMaxLength - tmpPrefixCost);
|
|
123
|
+
tmpFull = (tmpPrefix ? `${tmpPrefix}${tmpSeparator}` : '') + hashSegment(tmpBody, tmpBudget);
|
|
124
|
+
return (tmpFull.length > tmpMaxLength) ? tmpFull.slice(0, tmpMaxLength) : tmpFull;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
composeGUID,
|
|
129
|
+
hashSegment,
|
|
130
|
+
segmentString,
|
|
131
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Meadow-Integration-GUIDStrategy — compiles a high-level, per-entity GUID strategy into the structured
|
|
2
|
+
// spec the transform consumes (which `Service-TabularTransform.createRecordFromMapping` resolves per row
|
|
3
|
+
// via the GUID composer). This is the configurable brain behind context-aware import GUIDs.
|
|
4
|
+
//
|
|
5
|
+
// It does NOT touch fable or the DOM and produces only plain data (segment value-TEMPLATES, field names,
|
|
6
|
+
// length budgets) — so it is reusable by the CLI, the server endpoints, and the browser import wizard
|
|
7
|
+
// alike, and is trivially unit-testable.
|
|
8
|
+
//
|
|
9
|
+
// Three modes per entity AND per join, mapped onto the adapter's existing FK conventions:
|
|
10
|
+
// - prefixed (default): compose `UI_<context…>_<own>`; own → `GUID<Entity>`. A join to a parent that
|
|
11
|
+
// was uploaded in THIS run → `GUID<Parent>` (sync session GUIDMap); a join to a PRE-EXISTING parent
|
|
12
|
+
// (crossSession) → `_GUID<Parent>` (async server `getEntityByGUID` lookup).
|
|
13
|
+
// - raw: the source already carries a true Meadow GUID → `_GUID<Parent>` (join) / `GUID<Entity>` (own).
|
|
14
|
+
// - rawid: the source already carries a real `ID<Parent>` → used directly.
|
|
15
|
+
|
|
16
|
+
const _DEFAULT_PREFIX = 'UI';
|
|
17
|
+
const _DEFAULT_SEPARATOR = '_';
|
|
18
|
+
const _DEFAULT_HASH_LENGTH = 10;
|
|
19
|
+
|
|
20
|
+
/** A `{~D:Record.<column>~}` value template (what the transform resolves per row). */
|
|
21
|
+
function valueTemplate(pColumn)
|
|
22
|
+
{
|
|
23
|
+
return `{~D:Record.${pColumn}~}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Derive a short uppercase abbreviation for an entity with no catalog entry (initials / first letters). */
|
|
27
|
+
function _deriveAbbreviation(pEntityName)
|
|
28
|
+
{
|
|
29
|
+
const tmpName = String(pEntityName || '');
|
|
30
|
+
const tmpCapitals = tmpName.replace(/[^A-Z]/g, '');
|
|
31
|
+
if (tmpCapitals.length >= 2)
|
|
32
|
+
{
|
|
33
|
+
return tmpCapitals.slice(0, 3);
|
|
34
|
+
}
|
|
35
|
+
return tmpName.slice(0, 3).toUpperCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} pEntityName @param {Record<string, any>} pContext
|
|
40
|
+
* @returns {string} the host-fixed abbreviation (catalog) or a derived fallback
|
|
41
|
+
*/
|
|
42
|
+
function abbreviationFor(pEntityName, pContext)
|
|
43
|
+
{
|
|
44
|
+
const tmpCatalog = (pContext && pContext.Catalog) || {};
|
|
45
|
+
const tmpEntry = tmpCatalog[pEntityName];
|
|
46
|
+
if (tmpEntry && tmpEntry.Abbrev)
|
|
47
|
+
{
|
|
48
|
+
return String(tmpEntry.Abbrev);
|
|
49
|
+
}
|
|
50
|
+
return _deriveAbbreviation(pEntityName);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The GUID column width for an entity (from the live schema), or 0 (unbounded) if unknown. */
|
|
54
|
+
function _maxLengthFor(pEntityName, pContext)
|
|
55
|
+
{
|
|
56
|
+
const tmpSizes = (pContext && pContext.SchemaSizes) || {};
|
|
57
|
+
const tmpSize = Number(tmpSizes[pEntityName] || 0);
|
|
58
|
+
return (tmpSize > 0) ? tmpSize : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a compose spec (prefix + ordered segments + length budget) for an entity's fullGUID. */
|
|
62
|
+
function _composeSpec(pEntityName, pSegments, pContext)
|
|
63
|
+
{
|
|
64
|
+
return {
|
|
65
|
+
prefix: (pContext && (pContext.Prefix !== undefined)) ? pContext.Prefix : _DEFAULT_PREFIX,
|
|
66
|
+
separator: (pContext && pContext.Separator) || _DEFAULT_SEPARATOR,
|
|
67
|
+
hashLength: (pContext && pContext.HashLength) || _DEFAULT_HASH_LENGTH,
|
|
68
|
+
maxLength: _maxLengthFor(pEntityName, pContext),
|
|
69
|
+
segments: pSegments,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The ordered context + own segments for an entity's OWN fullGUID.
|
|
75
|
+
* @returns {{segments:Array<any>, warnings:Array<string>}}
|
|
76
|
+
*/
|
|
77
|
+
function _ownSegments(pEntityName, pEntityConfig, pContext)
|
|
78
|
+
{
|
|
79
|
+
const tmpWarnings = [];
|
|
80
|
+
const tmpSegments = [];
|
|
81
|
+
const tmpContextEntities = Array.isArray(pEntityConfig.ContextEntities) ? pEntityConfig.ContextEntities : [];
|
|
82
|
+
const tmpContextKeyColumns = pEntityConfig.ContextKeyColumns || {};
|
|
83
|
+
|
|
84
|
+
tmpContextEntities.forEach((pParentEntity) =>
|
|
85
|
+
{
|
|
86
|
+
const tmpKeyColumn = tmpContextKeyColumns[pParentEntity];
|
|
87
|
+
if (!tmpKeyColumn)
|
|
88
|
+
{
|
|
89
|
+
tmpWarnings.push(`Context entity "${pParentEntity}" for "${pEntityName}" has no key column mapped — its segment will be empty.`);
|
|
90
|
+
}
|
|
91
|
+
tmpSegments.push({ abbrev: abbreviationFor(pParentEntity, pContext), valueTemplate: tmpKeyColumn ? valueTemplate(tmpKeyColumn) : '' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!pEntityConfig.OwnKeyColumn)
|
|
95
|
+
{
|
|
96
|
+
tmpWarnings.push(`"${pEntityName}" has no own-key column mapped — its GUID cannot be stable, so re-imports will create duplicates. Map a natural-key column (e.g. a code).`);
|
|
97
|
+
}
|
|
98
|
+
tmpSegments.push({ abbrev: abbreviationFor(pEntityName, pContext), valueTemplate: pEntityConfig.OwnKeyColumn ? valueTemplate(pEntityConfig.OwnKeyColumn) : '' });
|
|
99
|
+
|
|
100
|
+
return { segments: tmpSegments, warnings: tmpWarnings };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compile the OWN GUID descriptor (field name + compose spec / raw template) for one entity. */
|
|
104
|
+
function _compileOwn(pEntityName, pEntityConfig, pContext)
|
|
105
|
+
{
|
|
106
|
+
const tmpGUIDName = `GUID${pEntityName}`;
|
|
107
|
+
const tmpMode = pEntityConfig.Mode || 'prefixed';
|
|
108
|
+
|
|
109
|
+
if (tmpMode === 'raw')
|
|
110
|
+
{
|
|
111
|
+
// Source carries a true Meadow GUID; with empty marshal prefixes it passes through GUID<Entity>.
|
|
112
|
+
return { Own: { Mode: 'raw', FieldName: tmpGUIDName, ValueTemplate: valueTemplate(pEntityConfig.OwnGUIDColumn || pEntityConfig.OwnKeyColumn) }, warnings: [] };
|
|
113
|
+
}
|
|
114
|
+
if (tmpMode === 'rawid')
|
|
115
|
+
{
|
|
116
|
+
// rawid OWN is deferred: the comprehension is keyed by GUID<Entity>, so an ID-only own record has
|
|
117
|
+
// no comprehension key. Use a join in rawid mode instead, or import such sheets via the raw path.
|
|
118
|
+
return { Own: { Mode: 'prefixed', FieldName: tmpGUIDName, Compose: _composeSpec(pEntityName, _ownSegments(pEntityName, pEntityConfig, pContext).segments, pContext) },
|
|
119
|
+
warnings: [ `"${pEntityName}" requested raw-ID own mode, which is not yet supported for the entity's own GUID — fell back to prefixed.` ] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// prefixed (default)
|
|
123
|
+
const tmpOwn = _ownSegments(pEntityName, pEntityConfig, pContext);
|
|
124
|
+
return { Own: { Mode: 'prefixed', FieldName: tmpGUIDName, Compose: _composeSpec(pEntityName, tmpOwn.segments, pContext) }, warnings: tmpOwn.warnings };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The FK field name for a join, per the adapter's conventions. */
|
|
128
|
+
function joinFieldName(pParentEntity, pMode, pCrossSession)
|
|
129
|
+
{
|
|
130
|
+
if (pMode === 'rawid')
|
|
131
|
+
{
|
|
132
|
+
return `ID${pParentEntity}`;
|
|
133
|
+
}
|
|
134
|
+
if (pMode === 'raw')
|
|
135
|
+
{
|
|
136
|
+
return `_GUID${pParentEntity}`;
|
|
137
|
+
}
|
|
138
|
+
// prefixed: sync (same-upload) vs async server lookup (pre-existing parent)
|
|
139
|
+
return pCrossSession ? `_GUID${pParentEntity}` : `GUID${pParentEntity}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Compile one join (FK) descriptor for an entity. */
|
|
143
|
+
function _compileJoin(pParentEntity, pJoin, pContext)
|
|
144
|
+
{
|
|
145
|
+
const tmpMode = pJoin.Mode || 'prefixed';
|
|
146
|
+
const tmpCrossSession = !!pJoin.CrossSession;
|
|
147
|
+
const tmpFieldName = joinFieldName(pParentEntity, tmpMode, tmpCrossSession);
|
|
148
|
+
const tmpWarnings = [];
|
|
149
|
+
|
|
150
|
+
if (tmpMode === 'raw' || tmpMode === 'rawid')
|
|
151
|
+
{
|
|
152
|
+
const tmpColumn = pJoin.GUIDColumn || pJoin.IDColumn || pJoin.KeyColumn;
|
|
153
|
+
if (!tmpColumn)
|
|
154
|
+
{
|
|
155
|
+
tmpWarnings.push(`Join "${pParentEntity}" (${tmpMode}) has no source column mapped.`);
|
|
156
|
+
}
|
|
157
|
+
return { Join: { ParentEntity: pParentEntity, Mode: tmpMode, FieldName: tmpFieldName, ValueTemplate: tmpColumn ? valueTemplate(tmpColumn) : '' }, warnings: tmpWarnings };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// prefixed: recompute the PARENT's fullGUID from this row's columns (so it matches the parent's own GUID).
|
|
161
|
+
if (!pJoin.KeyColumn)
|
|
162
|
+
{
|
|
163
|
+
tmpWarnings.push(`Join "${pParentEntity}" (prefixed) has no parent key column mapped — the foreign key will not resolve.`);
|
|
164
|
+
}
|
|
165
|
+
const tmpParentContextEntities = Array.isArray(pJoin.ContextEntities) ? pJoin.ContextEntities : [];
|
|
166
|
+
const tmpParentContextKeyColumns = pJoin.ContextKeyColumns || {};
|
|
167
|
+
const tmpSegments = tmpParentContextEntities.map((pGrandparent) => (
|
|
168
|
+
{ abbrev: abbreviationFor(pGrandparent, pContext), valueTemplate: tmpParentContextKeyColumns[pGrandparent] ? valueTemplate(tmpParentContextKeyColumns[pGrandparent]) : '' }));
|
|
169
|
+
tmpSegments.push({ abbrev: abbreviationFor(pParentEntity, pContext), valueTemplate: pJoin.KeyColumn ? valueTemplate(pJoin.KeyColumn) : '' });
|
|
170
|
+
|
|
171
|
+
return { Join: { ParentEntity: pParentEntity, Mode: 'prefixed', FieldName: tmpFieldName, Compose: _composeSpec(pParentEntity, tmpSegments, pContext) }, warnings: tmpWarnings };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Compile one entity's strategy (own GUID + joins).
|
|
176
|
+
* @param {string} pEntityName @param {Record<string, any>} pEntityConfig @param {Record<string, any>} pContext
|
|
177
|
+
* @returns {{Strategy:Record<string, any>, Warnings:Array<string>}}
|
|
178
|
+
*/
|
|
179
|
+
function compileEntity(pEntityName, pEntityConfig, pContext)
|
|
180
|
+
{
|
|
181
|
+
const tmpConfig = pEntityConfig || {};
|
|
182
|
+
const tmpWarnings = [];
|
|
183
|
+
|
|
184
|
+
const tmpOwn = _compileOwn(pEntityName, tmpConfig, pContext);
|
|
185
|
+
tmpWarnings.push(...tmpOwn.warnings);
|
|
186
|
+
|
|
187
|
+
const tmpJoins = [];
|
|
188
|
+
(Array.isArray(tmpConfig.Joins) ? tmpConfig.Joins : []).forEach((pJoin) =>
|
|
189
|
+
{
|
|
190
|
+
if (!pJoin || !pJoin.ParentEntity) { return; }
|
|
191
|
+
const tmpCompiled = _compileJoin(pJoin.ParentEntity, pJoin, pContext);
|
|
192
|
+
tmpJoins.push(tmpCompiled.Join);
|
|
193
|
+
tmpWarnings.push(...tmpCompiled.warnings);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
Strategy: { Entity: pEntityName, GUIDName: `GUID${pEntityName}`, Own: tmpOwn.Own, Joins: tmpJoins },
|
|
198
|
+
Warnings: tmpWarnings,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compile a whole import's GUID strategy.
|
|
204
|
+
* @param {Record<string, any>} pConfig - { Prefix?, Separator?, HashLength?, Entities:{ <name>: <entityConfig> } }
|
|
205
|
+
* @param {Record<string, any>} pContext - { Catalog:{ <entity>:{Abbrev,KeyField} }, SchemaSizes:{ <entity>:size } }
|
|
206
|
+
* @returns {{Strategies:Record<string, any>, Warnings:Array<string>}}
|
|
207
|
+
*/
|
|
208
|
+
function compile(pConfig, pContext)
|
|
209
|
+
{
|
|
210
|
+
const tmpConfig = pConfig || {};
|
|
211
|
+
const tmpContext = Object.assign(
|
|
212
|
+
{
|
|
213
|
+
Prefix: (tmpConfig.Prefix !== undefined) ? tmpConfig.Prefix : _DEFAULT_PREFIX,
|
|
214
|
+
Separator: tmpConfig.Separator || _DEFAULT_SEPARATOR,
|
|
215
|
+
HashLength: tmpConfig.HashLength || _DEFAULT_HASH_LENGTH,
|
|
216
|
+
},
|
|
217
|
+
pContext || {});
|
|
218
|
+
|
|
219
|
+
const tmpEntities = tmpConfig.Entities || {};
|
|
220
|
+
const tmpStrategies = {};
|
|
221
|
+
const tmpWarnings = [];
|
|
222
|
+
Object.keys(tmpEntities).forEach((pEntityName) =>
|
|
223
|
+
{
|
|
224
|
+
const tmpCompiled = compileEntity(pEntityName, tmpEntities[pEntityName], tmpContext);
|
|
225
|
+
tmpStrategies[pEntityName] = tmpCompiled.Strategy;
|
|
226
|
+
tmpWarnings.push(...tmpCompiled.Warnings);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return { Strategies: tmpStrategies, Warnings: tmpWarnings };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
compile,
|
|
234
|
+
compileEntity,
|
|
235
|
+
joinFieldName,
|
|
236
|
+
abbreviationFor,
|
|
237
|
+
valueTemplate,
|
|
238
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const libFableService = require('fable-serviceproviderbase');
|
|
2
2
|
|
|
3
|
+
const libGUIDComposer = require('../guid/Meadow-Integration-GUIDComposer.js');
|
|
4
|
+
|
|
3
5
|
/*
|
|
4
6
|
// Comprehension Parameters
|
|
5
7
|
// This can be *either* a mapping file, in the following format, or a set of parameters listed below. The mapping file lets you map columns way easier!
|
|
@@ -134,7 +136,17 @@ class MeadowIntegrationTabularTransform extends libFableService
|
|
|
134
136
|
{
|
|
135
137
|
let tmpRecord = ((typeof(pRecordPrototype) == 'object') && (pRecordPrototype != null)) ? JSON.parse(JSON.stringify(pRecordPrototype)) : {};
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
// Opt-in context-aware GUID strategy: compose the entity's own GUID + its foreign-key GUID/ID
|
|
140
|
+
// fields from a structured spec (length-safe + deterministic). Falls back to the flat GUIDTemplate
|
|
141
|
+
// when no strategy is attached, so every existing mapping behaves exactly as before.
|
|
142
|
+
if (pMapping.GUIDStrategy)
|
|
143
|
+
{
|
|
144
|
+
this._applyGUIDStrategy(tmpRecord, pMapping.GUIDStrategy, pRecord);
|
|
145
|
+
}
|
|
146
|
+
else
|
|
147
|
+
{
|
|
148
|
+
tmpRecord[pMapping.GUIDName] = this.fable.parseTemplate(pMapping.GUIDTemplate, pRecord);
|
|
149
|
+
}
|
|
138
150
|
|
|
139
151
|
let tmpKeys = Object.keys(pMapping.Mappings);
|
|
140
152
|
for (let i = 0; i < tmpKeys.length; i++)
|
|
@@ -153,6 +165,56 @@ class MeadowIntegrationTabularTransform extends libFableService
|
|
|
153
165
|
return tmpRecord;
|
|
154
166
|
}
|
|
155
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a GUID compose spec's segment value-templates against the row, then run the deterministic
|
|
170
|
+
* length-safe composer. The compiler (Meadow-Integration-GUIDStrategy) produced the spec.
|
|
171
|
+
* @param {Record<string, any>} pComposeSpec @param {Record<string, any>} pRecord
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
composeGUIDFromSpec(pComposeSpec, pRecord)
|
|
175
|
+
{
|
|
176
|
+
let tmpSegments = (Array.isArray(pComposeSpec.segments) ? pComposeSpec.segments : []).map((pSegment) =>
|
|
177
|
+
{
|
|
178
|
+
return {
|
|
179
|
+
abbrev: pSegment.abbrev,
|
|
180
|
+
value: pSegment.valueTemplate ? this.fable.parseTemplate(pSegment.valueTemplate, pRecord) : '',
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
return libGUIDComposer.composeGUID(
|
|
184
|
+
{
|
|
185
|
+
prefix: pComposeSpec.prefix,
|
|
186
|
+
separator: pComposeSpec.separator,
|
|
187
|
+
maxLength: pComposeSpec.maxLength,
|
|
188
|
+
hashLength: pComposeSpec.hashLength,
|
|
189
|
+
segments: tmpSegments,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Stamp an entity's own GUID + its foreign-key fields onto the record from a compiled GUID strategy.
|
|
195
|
+
* Own / each join is one of: prefixed (Compose spec) or raw/rawid (a resolved source-column template).
|
|
196
|
+
* @param {Record<string, any>} pTargetRecord @param {Record<string, any>} pStrategy @param {Record<string, any>} pRecord
|
|
197
|
+
*/
|
|
198
|
+
_applyGUIDStrategy(pTargetRecord, pStrategy, pRecord)
|
|
199
|
+
{
|
|
200
|
+
let tmpOwn = pStrategy.Own || {};
|
|
201
|
+
if (tmpOwn.FieldName)
|
|
202
|
+
{
|
|
203
|
+
pTargetRecord[tmpOwn.FieldName] = tmpOwn.Compose
|
|
204
|
+
? this.composeGUIDFromSpec(tmpOwn.Compose, pRecord)
|
|
205
|
+
: (tmpOwn.ValueTemplate ? this.fable.parseTemplate(tmpOwn.ValueTemplate, pRecord) : '');
|
|
206
|
+
}
|
|
207
|
+
let tmpJoins = Array.isArray(pStrategy.Joins) ? pStrategy.Joins : [];
|
|
208
|
+
for (let i = 0; i < tmpJoins.length; i++)
|
|
209
|
+
{
|
|
210
|
+
let tmpJoin = tmpJoins[i];
|
|
211
|
+
if (!tmpJoin || !tmpJoin.FieldName) { continue; }
|
|
212
|
+
pTargetRecord[tmpJoin.FieldName] = tmpJoin.Compose
|
|
213
|
+
? this.composeGUIDFromSpec(tmpJoin.Compose, pRecord)
|
|
214
|
+
: (tmpJoin.ValueTemplate ? this.fable.parseTemplate(tmpJoin.ValueTemplate, pRecord) : '');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
156
218
|
addRecordToComprehension(pIncomingRecord, pMappingOutcome, pNewRecordPrototype, pGUIDUniquenessString)
|
|
157
219
|
{
|
|
158
220
|
let tmpNewRecordPrototype = (typeof(pNewRecordPrototype) == 'object') ? pNewRecordPrototype : {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for the context-aware GUID composer (Meadow-Integration-GUIDComposer).
|
|
3
|
+
|
|
4
|
+
Pure module — no fable, no network. Verifies deterministic composition, the length-safe shrink-by-hash
|
|
5
|
+
behavior across the real GUID column widths (36 / 64 / 128), and idempotency (same input → same GUID).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const Chai = require('chai');
|
|
9
|
+
const Expect = Chai.expect;
|
|
10
|
+
|
|
11
|
+
const libComposer = require('../source/services/guid/Meadow-Integration-GUIDComposer.js');
|
|
12
|
+
|
|
13
|
+
const projectSpec = (pCode, pMaxLength) => (
|
|
14
|
+
{
|
|
15
|
+
prefix: 'UI',
|
|
16
|
+
maxLength: pMaxLength,
|
|
17
|
+
segments: [ { abbrev: 'P', value: pCode } ],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const lineItemSpec = (pContract, pProject, pLineId, pMaxLength) => (
|
|
21
|
+
{
|
|
22
|
+
prefix: 'UI',
|
|
23
|
+
maxLength: pMaxLength,
|
|
24
|
+
segments: [ { abbrev: 'C', value: pContract }, { abbrev: 'P', value: pProject }, { abbrev: 'LI', value: pLineId } ],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
suite
|
|
28
|
+
(
|
|
29
|
+
'Meadow Integration — GUID Composer',
|
|
30
|
+
() =>
|
|
31
|
+
{
|
|
32
|
+
suite
|
|
33
|
+
(
|
|
34
|
+
'composition',
|
|
35
|
+
() =>
|
|
36
|
+
{
|
|
37
|
+
test
|
|
38
|
+
(
|
|
39
|
+
'composes prefix + segments with underscores (the headline example)',
|
|
40
|
+
() =>
|
|
41
|
+
{
|
|
42
|
+
const tmpGUID = libComposer.composeGUID(lineItemSpec('10', '01278', '8675309', 64));
|
|
43
|
+
Expect(tmpGUID).to.equal('UI_C10_P01278_LI8675309');
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
test
|
|
47
|
+
(
|
|
48
|
+
'a top-level entity composes prefix + own segment',
|
|
49
|
+
() =>
|
|
50
|
+
{
|
|
51
|
+
Expect(libComposer.composeGUID(projectSpec('01278', 64))).to.equal('UI_P01278');
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
test
|
|
55
|
+
(
|
|
56
|
+
'omits the prefix (and its separator) when falsy',
|
|
57
|
+
() =>
|
|
58
|
+
{
|
|
59
|
+
const tmpGUID = libComposer.composeGUID({ prefix: '', maxLength: 64, segments: [ { abbrev: 'P', value: '01278' } ] });
|
|
60
|
+
Expect(tmpGUID).to.equal('P01278');
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
test
|
|
64
|
+
(
|
|
65
|
+
'a child FK recomputed from the parent spec matches the parent\'s own GUID',
|
|
66
|
+
() =>
|
|
67
|
+
{
|
|
68
|
+
// Parent (Project) own GUID, and the same Project spec evaluated on the child's row values.
|
|
69
|
+
const tmpParentOwn = libComposer.composeGUID(projectSpec('01278', 64));
|
|
70
|
+
const tmpChildFK = libComposer.composeGUID(projectSpec('01278', 64));
|
|
71
|
+
Expect(tmpChildFK).to.equal(tmpParentOwn);
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
suite
|
|
77
|
+
(
|
|
78
|
+
'determinism',
|
|
79
|
+
() =>
|
|
80
|
+
{
|
|
81
|
+
test
|
|
82
|
+
(
|
|
83
|
+
'same input → same GUID, every time',
|
|
84
|
+
() =>
|
|
85
|
+
{
|
|
86
|
+
const tmpA = libComposer.composeGUID(lineItemSpec('10', 'VERYLONGPROJECTCODE-001278-REGION-NORTH', '8675309', 36));
|
|
87
|
+
const tmpB = libComposer.composeGUID(lineItemSpec('10', 'VERYLONGPROJECTCODE-001278-REGION-NORTH', '8675309', 36));
|
|
88
|
+
Expect(tmpA).to.equal(tmpB);
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
test
|
|
92
|
+
(
|
|
93
|
+
'hashSegment is deterministic + length-bounded',
|
|
94
|
+
() =>
|
|
95
|
+
{
|
|
96
|
+
Expect(libComposer.hashSegment('P01278', 10)).to.equal(libComposer.hashSegment('P01278', 10));
|
|
97
|
+
Expect(libComposer.hashSegment('P01278', 10).length).to.be.at.most(10);
|
|
98
|
+
Expect(libComposer.hashSegment('A')).to.not.equal(libComposer.hashSegment('B'));
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
suite
|
|
104
|
+
(
|
|
105
|
+
'length-safety (real column widths)',
|
|
106
|
+
() =>
|
|
107
|
+
{
|
|
108
|
+
test
|
|
109
|
+
(
|
|
110
|
+
'fits inside a 36-char column by hashing, staying prefixed + stable',
|
|
111
|
+
() =>
|
|
112
|
+
{
|
|
113
|
+
const tmpSpec = lineItemSpec('CONTRACT-2026-NORTH', 'PROJECT-01278-HIGHWAY-10-REPAVE', 'LINEITEM-8675309-CONCRETE', 36);
|
|
114
|
+
const tmpGUID = libComposer.composeGUID(tmpSpec);
|
|
115
|
+
Expect(tmpGUID.length, tmpGUID).to.be.at.most(36);
|
|
116
|
+
Expect(tmpGUID.indexOf('UI_'), 'keeps the UI prefix').to.equal(0);
|
|
117
|
+
// idempotent
|
|
118
|
+
Expect(libComposer.composeGUID(tmpSpec)).to.equal(tmpGUID);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
test
|
|
122
|
+
(
|
|
123
|
+
'a 64-char column keeps the readable form when it already fits',
|
|
124
|
+
() =>
|
|
125
|
+
{
|
|
126
|
+
Expect(libComposer.composeGUID(lineItemSpec('10', '01278', '8675309', 64))).to.equal('UI_C10_P01278_LI8675309');
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
test
|
|
130
|
+
(
|
|
131
|
+
'a 128-char column has ample room (PhysicalAsset-like)',
|
|
132
|
+
() =>
|
|
133
|
+
{
|
|
134
|
+
const tmpGUID = libComposer.composeGUID(lineItemSpec('10', '01278', '8675309', 128));
|
|
135
|
+
Expect(tmpGUID).to.equal('UI_C10_P01278_LI8675309');
|
|
136
|
+
Expect(tmpGUID.length).to.be.at.most(128);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
test
|
|
140
|
+
(
|
|
141
|
+
'an unbounded (maxLength <= 0) compose never hashes',
|
|
142
|
+
() =>
|
|
143
|
+
{
|
|
144
|
+
const tmpGUID = libComposer.composeGUID(lineItemSpec('CONTRACT-2026', 'PROJECT-01278-HIGHWAY', 'LINEITEM-8675309', 0));
|
|
145
|
+
Expect(tmpGUID).to.equal('UI_CCONTRACT-2026_PPROJECT-01278-HIGHWAY_LILINEITEM-8675309');
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
test
|
|
149
|
+
(
|
|
150
|
+
'a pathologically tiny column still produces a stable, in-budget GUID',
|
|
151
|
+
() =>
|
|
152
|
+
{
|
|
153
|
+
const tmpSpec = lineItemSpec('CONTRACT-LONG', 'PROJECT-LONG', 'LINEITEM-LONG', 16);
|
|
154
|
+
const tmpGUID = libComposer.composeGUID(tmpSpec);
|
|
155
|
+
Expect(tmpGUID.length, tmpGUID).to.be.at.most(16);
|
|
156
|
+
Expect(libComposer.composeGUID(tmpSpec)).to.equal(tmpGUID);
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Integration test: the GUID strategy flows end-to-end through the real TabularTransform.
|
|
3
|
+
|
|
4
|
+
Compiler (config → spec) → transform (resolves segment templates per row) → composer (length-safe) →
|
|
5
|
+
comprehension record with the entity's own GUID + its foreign-key GUID/ID fields. Uses a pict instance
|
|
6
|
+
as the host fable (pict provides `parseTemplate`) — the same shape the browser import wizard passes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const Chai = require('chai');
|
|
10
|
+
const Expect = Chai.expect;
|
|
11
|
+
|
|
12
|
+
const libPict = require('pict');
|
|
13
|
+
|
|
14
|
+
const libEngine = require('../source/Meadow-Integration-Engine.js');
|
|
15
|
+
|
|
16
|
+
const newTransform = () =>
|
|
17
|
+
{
|
|
18
|
+
const tmpPict = new libPict({ Product: 'GUIDStrategyTransformTest', LogStreams: [ { streamtype: 'console', level: 'error' } ] });
|
|
19
|
+
return new libEngine.MeadowIntegrationTabularTransform(tmpPict);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const CATALOG =
|
|
23
|
+
{
|
|
24
|
+
Project: { Abbrev: 'P' },
|
|
25
|
+
Contract: { Abbrev: 'C' },
|
|
26
|
+
LineItem: { Abbrev: 'LI' },
|
|
27
|
+
};
|
|
28
|
+
const SIZES = { Project: 64, Contract: 64, LineItem: 64 };
|
|
29
|
+
|
|
30
|
+
// Build the comprehension for one entity mapping over one row (mirrors DataImport-ComprehensionBuilder).
|
|
31
|
+
const runOneEntity = (pTransform, pMapping, pRow) =>
|
|
32
|
+
{
|
|
33
|
+
const tmpOutcome = pTransform.newMappingOutcomeObject();
|
|
34
|
+
tmpOutcome.ImplicitConfiguration = {};
|
|
35
|
+
tmpOutcome.ExplicitConfiguration = pMapping;
|
|
36
|
+
pTransform.initializeMappingOutcomeObject(tmpOutcome);
|
|
37
|
+
pTransform.transformRecord(pRow, tmpOutcome);
|
|
38
|
+
return tmpOutcome.Comprehension[pMapping.Entity] || {};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
suite
|
|
42
|
+
(
|
|
43
|
+
'Meadow Integration — GUID strategy through the transform',
|
|
44
|
+
() =>
|
|
45
|
+
{
|
|
46
|
+
test
|
|
47
|
+
(
|
|
48
|
+
'composes a top-level entity own GUID from the strategy',
|
|
49
|
+
() =>
|
|
50
|
+
{
|
|
51
|
+
const tmpTransform = newTransform();
|
|
52
|
+
const tmpStrategy = libEngine.compileGUIDStrategy(
|
|
53
|
+
{ Prefix: 'UI', Entities: { Project: { Mode: 'prefixed', OwnKeyColumn: 'ProjectCode' } } },
|
|
54
|
+
{ Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.Project;
|
|
55
|
+
const tmpMapping = { Entity: 'Project', GUIDName: 'GUIDProject', GUIDStrategy: tmpStrategy, Mappings: { Name: '{~D:Record.ProjectName~}' } };
|
|
56
|
+
|
|
57
|
+
const tmpComprehension = runOneEntity(tmpTransform, tmpMapping, { ProjectCode: '01278', ProjectName: 'Highway 10 Repave' });
|
|
58
|
+
const tmpKeys = Object.keys(tmpComprehension);
|
|
59
|
+
Expect(tmpKeys).to.deep.equal([ 'UI_P01278' ]);
|
|
60
|
+
Expect(tmpComprehension['UI_P01278'].GUIDProject).to.equal('UI_P01278');
|
|
61
|
+
Expect(tmpComprehension['UI_P01278'].Name).to.equal('Highway 10 Repave');
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
test
|
|
65
|
+
(
|
|
66
|
+
'composes a child own GUID (context chain) + cross-session FK fields',
|
|
67
|
+
() =>
|
|
68
|
+
{
|
|
69
|
+
const tmpTransform = newTransform();
|
|
70
|
+
const tmpStrategy = libEngine.compileGUIDStrategy(
|
|
71
|
+
{
|
|
72
|
+
Prefix: 'UI',
|
|
73
|
+
Entities:
|
|
74
|
+
{
|
|
75
|
+
LineItem:
|
|
76
|
+
{
|
|
77
|
+
Mode: 'prefixed',
|
|
78
|
+
OwnKeyColumn: 'LineId',
|
|
79
|
+
ContextEntities: [ 'Contract', 'Project' ],
|
|
80
|
+
ContextKeyColumns: { Contract: 'ContractNum', Project: 'ProjectCode' },
|
|
81
|
+
Joins:
|
|
82
|
+
[
|
|
83
|
+
{ ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: true },
|
|
84
|
+
{ ParentEntity: 'Contract', Mode: 'prefixed', KeyColumn: 'ContractNum', CrossSession: true },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{ Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem;
|
|
90
|
+
const tmpMapping = { Entity: 'LineItem', GUIDName: 'GUIDLineItem', GUIDStrategy: tmpStrategy, Mappings: { Quantity: '{~D:Record.qty~}' } };
|
|
91
|
+
|
|
92
|
+
const tmpRow = { ContractNum: '10', ProjectCode: '01278', LineId: '8675309', qty: '5' };
|
|
93
|
+
const tmpComprehension = runOneEntity(tmpTransform, tmpMapping, tmpRow);
|
|
94
|
+
const tmpRecord = tmpComprehension['UI_C10_P01278_LI8675309'];
|
|
95
|
+
Expect(tmpRecord, 'record keyed by composed own GUID').to.be.an('object');
|
|
96
|
+
Expect(tmpRecord.GUIDLineItem).to.equal('UI_C10_P01278_LI8675309');
|
|
97
|
+
// Cross-session FK fields use the `_GUID<Parent>` async-lookup convention + the parent's own GUID.
|
|
98
|
+
Expect(tmpRecord._GUIDProject).to.equal('UI_P01278');
|
|
99
|
+
Expect(tmpRecord._GUIDContract).to.equal('UI_C10');
|
|
100
|
+
Expect(tmpRecord.Quantity).to.equal('5');
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
test
|
|
104
|
+
(
|
|
105
|
+
'the child FK equals the parent\'s own GUID (so the join resolves) — and is idempotent',
|
|
106
|
+
() =>
|
|
107
|
+
{
|
|
108
|
+
const tmpTransform = newTransform();
|
|
109
|
+
const tmpCompiled = libEngine.compileGUIDStrategy(
|
|
110
|
+
{
|
|
111
|
+
Prefix: 'UI',
|
|
112
|
+
Entities:
|
|
113
|
+
{
|
|
114
|
+
Project: { Mode: 'prefixed', OwnKeyColumn: 'ProjectCode' },
|
|
115
|
+
LineItem: { Mode: 'prefixed', OwnKeyColumn: 'LineId', Joins: [ { ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: false } ] },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{ Catalog: CATALOG, SchemaSizes: SIZES }).Strategies;
|
|
119
|
+
|
|
120
|
+
const tmpProjectComp = runOneEntity(tmpTransform, { Entity: 'Project', GUIDName: 'GUIDProject', GUIDStrategy: tmpCompiled.Project, Mappings: {} }, { ProjectCode: '01278' });
|
|
121
|
+
const tmpLineComp = runOneEntity(tmpTransform, { Entity: 'LineItem', GUIDName: 'GUIDLineItem', GUIDStrategy: tmpCompiled.LineItem, Mappings: {} }, { ProjectCode: '01278', LineId: '42' });
|
|
122
|
+
|
|
123
|
+
const tmpProjectGUID = Object.keys(tmpProjectComp)[0];
|
|
124
|
+
// same-upload join → sync `GUID<Parent>` field, value == the Project's own GUID
|
|
125
|
+
Expect(tmpLineComp['UI_LI42'].GUIDProject).to.equal(tmpProjectGUID);
|
|
126
|
+
// re-running yields the identical record (idempotent)
|
|
127
|
+
const tmpLineComp2 = runOneEntity(newTransform(), { Entity: 'LineItem', GUIDName: 'GUIDLineItem', GUIDStrategy: tmpCompiled.LineItem, Mappings: {} }, { ProjectCode: '01278', LineId: '42' });
|
|
128
|
+
Expect(Object.keys(tmpLineComp2)).to.deep.equal([ 'UI_LI42' ]);
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
test
|
|
132
|
+
(
|
|
133
|
+
'a non-strategy mapping still uses the flat GUIDTemplate (no behavior change)',
|
|
134
|
+
() =>
|
|
135
|
+
{
|
|
136
|
+
const tmpTransform = newTransform();
|
|
137
|
+
const tmpMapping = { Entity: 'Airport', GUIDName: 'GUIDAirport', GUIDTemplate: 'Airport_{~D:Record.iata~}', Mappings: { Name: '{~D:Record.name~}' } };
|
|
138
|
+
const tmpComprehension = runOneEntity(tmpTransform, tmpMapping, { iata: 'BTR', name: 'Baton Rouge' });
|
|
139
|
+
Expect(Object.keys(tmpComprehension)).to.deep.equal([ 'Airport_BTR' ]);
|
|
140
|
+
Expect(tmpComprehension['Airport_BTR'].Name).to.equal('Baton Rouge');
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for the GUID strategy compiler (Meadow-Integration-GUIDStrategy).
|
|
3
|
+
|
|
4
|
+
Pure module — turns a per-entity strategy config + a host context catalog + schema GUID sizes into the
|
|
5
|
+
structured spec the transform consumes. Verifies own/context composition, the per-join FK field-name
|
|
6
|
+
selection (mapping to the adapter's GUID / _GUID / ID conventions), and the no-key warning.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const Chai = require('chai');
|
|
10
|
+
const Expect = Chai.expect;
|
|
11
|
+
|
|
12
|
+
const libStrategy = require('../source/services/guid/Meadow-Integration-GUIDStrategy.js');
|
|
13
|
+
const libComposer = require('../source/services/guid/Meadow-Integration-GUIDComposer.js');
|
|
14
|
+
|
|
15
|
+
const CATALOG =
|
|
16
|
+
{
|
|
17
|
+
Project: { Abbrev: 'P', KeyField: 'Code' },
|
|
18
|
+
Contract: { Abbrev: 'C', KeyField: 'Number' },
|
|
19
|
+
LineItem: { Abbrev: 'LI', KeyField: 'Code' },
|
|
20
|
+
};
|
|
21
|
+
const SIZES = { Project: 64, Contract: 64, LineItem: 64 };
|
|
22
|
+
|
|
23
|
+
const CONFIG =
|
|
24
|
+
{
|
|
25
|
+
Prefix: 'UI',
|
|
26
|
+
Entities:
|
|
27
|
+
{
|
|
28
|
+
Project: { Mode: 'prefixed', OwnKeyColumn: 'ProjectCode' },
|
|
29
|
+
LineItem:
|
|
30
|
+
{
|
|
31
|
+
Mode: 'prefixed',
|
|
32
|
+
OwnKeyColumn: 'LineId',
|
|
33
|
+
ContextEntities: [ 'Contract', 'Project' ],
|
|
34
|
+
ContextKeyColumns: { Contract: 'ContractNum', Project: 'ProjectCode' },
|
|
35
|
+
Joins:
|
|
36
|
+
[
|
|
37
|
+
{ ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: true },
|
|
38
|
+
{ ParentEntity: 'Contract', Mode: 'prefixed', KeyColumn: 'ContractNum', CrossSession: true },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
suite
|
|
45
|
+
(
|
|
46
|
+
'Meadow Integration — GUID Strategy compiler',
|
|
47
|
+
() =>
|
|
48
|
+
{
|
|
49
|
+
test
|
|
50
|
+
(
|
|
51
|
+
'compiles a top-level entity to a single-segment own GUID spec',
|
|
52
|
+
() =>
|
|
53
|
+
{
|
|
54
|
+
const tmpResult = libStrategy.compile(CONFIG, { Catalog: CATALOG, SchemaSizes: SIZES });
|
|
55
|
+
const tmpProject = tmpResult.Strategies.Project;
|
|
56
|
+
Expect(tmpProject.GUIDName).to.equal('GUIDProject');
|
|
57
|
+
Expect(tmpProject.Own.FieldName).to.equal('GUIDProject');
|
|
58
|
+
Expect(tmpProject.Own.Compose.prefix).to.equal('UI');
|
|
59
|
+
Expect(tmpProject.Own.Compose.maxLength).to.equal(64);
|
|
60
|
+
Expect(tmpProject.Own.Compose.segments).to.have.length(1);
|
|
61
|
+
Expect(tmpProject.Own.Compose.segments[0]).to.deep.equal({ abbrev: 'P', valueTemplate: '{~D:Record.ProjectCode~}' });
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
test
|
|
65
|
+
(
|
|
66
|
+
'composes the headline LineItem own GUID from context + own segments',
|
|
67
|
+
() =>
|
|
68
|
+
{
|
|
69
|
+
const tmpLineItem = libStrategy.compile(CONFIG, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem;
|
|
70
|
+
const tmpSegments = tmpLineItem.Own.Compose.segments;
|
|
71
|
+
Expect(tmpSegments).to.have.length(3);
|
|
72
|
+
Expect(tmpSegments.map((s) => s.abbrev)).to.deep.equal([ 'C', 'P', 'LI' ]);
|
|
73
|
+
// Resolve the templates against a sample row + run the composer → the headline string.
|
|
74
|
+
const tmpRow = { ContractNum: '10', ProjectCode: '01278', LineId: '8675309' };
|
|
75
|
+
const tmpResolved = tmpSegments.map((s) => ({ abbrev: s.abbrev, value: tmpRow[s.valueTemplate.replace('{~D:Record.', '').replace('~}', '')] }));
|
|
76
|
+
Expect(libComposer.composeGUID({ prefix: 'UI', maxLength: 64, segments: tmpResolved })).to.equal('UI_C10_P01278_LI8675309');
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
test
|
|
80
|
+
(
|
|
81
|
+
'a cross-session prefixed join emits `_GUID<Parent>` (async server lookup) + the parent compose spec',
|
|
82
|
+
() =>
|
|
83
|
+
{
|
|
84
|
+
const tmpLineItem = libStrategy.compile(CONFIG, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem;
|
|
85
|
+
const tmpProjectJoin = tmpLineItem.Joins.find((j) => j.ParentEntity === 'Project');
|
|
86
|
+
Expect(tmpProjectJoin.FieldName).to.equal('_GUIDProject');
|
|
87
|
+
Expect(tmpProjectJoin.Compose.segments).to.deep.equal([ { abbrev: 'P', valueTemplate: '{~D:Record.ProjectCode~}' } ]);
|
|
88
|
+
Expect(tmpProjectJoin.Compose.maxLength).to.equal(64);
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
test
|
|
92
|
+
(
|
|
93
|
+
'join field-name selection covers all modes',
|
|
94
|
+
() =>
|
|
95
|
+
{
|
|
96
|
+
Expect(libStrategy.joinFieldName('Project', 'prefixed', false)).to.equal('GUIDProject'); // same-upload, sync
|
|
97
|
+
Expect(libStrategy.joinFieldName('Project', 'prefixed', true)).to.equal('_GUIDProject'); // cross-session, async
|
|
98
|
+
Expect(libStrategy.joinFieldName('Project', 'raw', false)).to.equal('_GUIDProject'); // raw meadow GUID
|
|
99
|
+
Expect(libStrategy.joinFieldName('Project', 'rawid', false)).to.equal('IDProject'); // raw id
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
test
|
|
103
|
+
(
|
|
104
|
+
'a same-upload prefixed join emits sync `GUID<Parent>`',
|
|
105
|
+
() =>
|
|
106
|
+
{
|
|
107
|
+
const tmpConfig = { Prefix: 'UI', Entities: { LineItem: { OwnKeyColumn: 'LineId', Joins: [ { ParentEntity: 'Project', Mode: 'prefixed', KeyColumn: 'ProjectCode', CrossSession: false } ] } } };
|
|
108
|
+
const tmpJoin = libStrategy.compile(tmpConfig, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem.Joins[0];
|
|
109
|
+
Expect(tmpJoin.FieldName).to.equal('GUIDProject');
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
test
|
|
113
|
+
(
|
|
114
|
+
'raw + rawid joins resolve a source column directly',
|
|
115
|
+
() =>
|
|
116
|
+
{
|
|
117
|
+
const tmpConfig =
|
|
118
|
+
{
|
|
119
|
+
Entities:
|
|
120
|
+
{
|
|
121
|
+
LineItem:
|
|
122
|
+
{
|
|
123
|
+
OwnKeyColumn: 'LineId',
|
|
124
|
+
Joins:
|
|
125
|
+
[
|
|
126
|
+
{ ParentEntity: 'Project', Mode: 'raw', GUIDColumn: 'ProjectGUID' },
|
|
127
|
+
{ ParentEntity: 'Contract', Mode: 'rawid', IDColumn: 'ContractID' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const tmpJoins = libStrategy.compile(tmpConfig, { Catalog: CATALOG, SchemaSizes: SIZES }).Strategies.LineItem.Joins;
|
|
133
|
+
Expect(tmpJoins[0]).to.deep.include({ FieldName: '_GUIDProject', Mode: 'raw', ValueTemplate: '{~D:Record.ProjectGUID~}' });
|
|
134
|
+
Expect(tmpJoins[1]).to.deep.include({ FieldName: 'IDContract', Mode: 'rawid', ValueTemplate: '{~D:Record.ContractID~}' });
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
test
|
|
138
|
+
(
|
|
139
|
+
'warns (does not throw) when an entity has no own-key column — the dup-on-reimport fallback',
|
|
140
|
+
() =>
|
|
141
|
+
{
|
|
142
|
+
const tmpResult = libStrategy.compile({ Entities: { Widget: { Mode: 'prefixed' } } }, { Catalog: {}, SchemaSizes: {} });
|
|
143
|
+
Expect(tmpResult.Warnings.join(' ')).to.match(/no own-key column/i);
|
|
144
|
+
// still produces a usable strategy (derived abbreviation, unbounded length)
|
|
145
|
+
Expect(tmpResult.Strategies.Widget.Own.FieldName).to.equal('GUIDWidget');
|
|
146
|
+
Expect(tmpResult.Strategies.Widget.Own.Compose.segments[0].abbrev).to.equal('WID');
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for the adapter's opt-in GUIDTruncationStrategy (the marshaling-path length safety).
|
|
3
|
+
|
|
4
|
+
"substring" (default) preserves the external GUID and truncates the prefix — existing behavior.
|
|
5
|
+
"hash" keeps the prefix and deterministically hashes the external GUID to fit — stable across runs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const Chai = require('chai');
|
|
9
|
+
const Expect = Chai.expect;
|
|
10
|
+
|
|
11
|
+
const libFable = require('fable');
|
|
12
|
+
|
|
13
|
+
const libEngine = require('../source/Meadow-Integration-Engine.js');
|
|
14
|
+
|
|
15
|
+
const newAdapter = (pOptions) =>
|
|
16
|
+
{
|
|
17
|
+
const tmpFable = new libFable({ Product: 'GUIDTruncTest', LogStreams: [ { streamtype: 'console', level: 'error' } ] });
|
|
18
|
+
return new libEngine.MeadowIntegrationAdapter(tmpFable, Object.assign(
|
|
19
|
+
{
|
|
20
|
+
Entity: 'Specification',
|
|
21
|
+
AllowGUIDTruncation: true,
|
|
22
|
+
GUIDMaxLength: 36,
|
|
23
|
+
AdapterSetGUIDMarshalPrefix: 'HLICLI',
|
|
24
|
+
EntityGUIDMarshalPrefix: 'E-Specification',
|
|
25
|
+
}, pOptions || {}), 'GUIDTruncTest');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const EXTERNAL = 'SpecSet-2026-001278'; // 19 chars; prefix + this exceeds 36, exercising truncation
|
|
29
|
+
|
|
30
|
+
suite
|
|
31
|
+
(
|
|
32
|
+
'Meadow Integration — adapter GUID truncation strategy',
|
|
33
|
+
() =>
|
|
34
|
+
{
|
|
35
|
+
test
|
|
36
|
+
(
|
|
37
|
+
'substring (default) keeps the external GUID whole + truncates the prefix',
|
|
38
|
+
() =>
|
|
39
|
+
{
|
|
40
|
+
const tmpAdapter = newAdapter({ GUIDTruncationStrategy: 'substring' });
|
|
41
|
+
const tmpGUID = tmpAdapter.generateMeadowGUIDFromExternalGUID(EXTERNAL);
|
|
42
|
+
Expect(tmpGUID.length, tmpGUID).to.be.at.most(36);
|
|
43
|
+
Expect(tmpGUID.endsWith(EXTERNAL), 'external GUID preserved at the tail').to.equal(true);
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
test
|
|
47
|
+
(
|
|
48
|
+
'hash keeps the prefix whole + hashes the external GUID to fit, deterministically',
|
|
49
|
+
() =>
|
|
50
|
+
{
|
|
51
|
+
const tmpAdapter = newAdapter({ GUIDTruncationStrategy: 'hash' });
|
|
52
|
+
const tmpGUID = tmpAdapter.generateMeadowGUIDFromExternalGUID(EXTERNAL);
|
|
53
|
+
Expect(tmpGUID.length, tmpGUID).to.be.at.most(36);
|
|
54
|
+
Expect(tmpGUID.indexOf(tmpAdapter.GUIDPrefix), 'prefix preserved at the head').to.equal(0);
|
|
55
|
+
Expect(tmpGUID).to.not.equal(EXTERNAL);
|
|
56
|
+
// deterministic across adapter instances
|
|
57
|
+
Expect(newAdapter({ GUIDTruncationStrategy: 'hash' }).generateMeadowGUIDFromExternalGUID(EXTERNAL)).to.equal(tmpGUID);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
test
|
|
61
|
+
(
|
|
62
|
+
'a short GUID is unchanged regardless of strategy',
|
|
63
|
+
() =>
|
|
64
|
+
{
|
|
65
|
+
const tmpShort = 'ABC';
|
|
66
|
+
Expect(newAdapter({ GUIDTruncationStrategy: 'hash' }).generateMeadowGUIDFromExternalGUID(tmpShort))
|
|
67
|
+
.to.equal(newAdapter({ GUIDTruncationStrategy: 'substring' }).generateMeadowGUIDFromExternalGUID(tmpShort));
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
);
|