@api-client/core 0.18.24 → 0.18.26
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/build/src/modeling/DomainEntity.d.ts +6 -1
- package/build/src/modeling/DomainEntity.d.ts.map +1 -1
- package/build/src/modeling/DomainEntity.js +24 -5
- package/build/src/modeling/DomainEntity.js.map +1 -1
- package/build/src/modeling/Semantics.d.ts +254 -0
- package/build/src/modeling/Semantics.d.ts.map +1 -1
- package/build/src/modeling/Semantics.js +328 -0
- package/build/src/modeling/Semantics.js.map +1 -1
- package/build/src/modeling/definitions/Email.js +1 -1
- package/build/src/modeling/definitions/Email.js.map +1 -1
- package/build/src/modeling/definitions/Password.d.ts.map +1 -1
- package/build/src/modeling/definitions/Password.js +1 -3
- package/build/src/modeling/definitions/Password.js.map +1 -1
- package/build/src/modeling/helpers/Intelisense.d.ts +7 -7
- package/build/src/modeling/helpers/Intelisense.d.ts.map +1 -1
- package/build/src/modeling/helpers/Intelisense.js +24 -58
- package/build/src/modeling/helpers/Intelisense.js.map +1 -1
- package/build/src/modeling/templates/meta/blog-publishing-platform.json +1 -1
- package/build/src/modeling/templates/meta/financial-services-platform.json +1 -1
- package/build/src/modeling/templates/meta/index.d.ts +1 -1
- package/build/src/modeling/templates/meta/index.js +1 -1
- package/build/src/modeling/templates/meta/index.js.map +1 -1
- package/build/src/modeling/templates/meta/iot-smart-home-platform.json +1 -1
- package/build/src/modeling/templates/verticals/business-services/financial-services-domain.d.ts.map +1 -1
- package/build/src/modeling/templates/verticals/business-services/financial-services-domain.js +248 -63
- package/build/src/modeling/templates/verticals/business-services/financial-services-domain.js.map +1 -1
- package/build/src/modeling/templates/verticals/technology-media/blog-domain.js +5 -5
- package/build/src/modeling/templates/verticals/technology-media/blog-domain.js.map +1 -1
- package/build/src/modeling/templates/verticals/technology-media/iot-smart-home-domain.d.ts.map +1 -1
- package/build/src/modeling/templates/verticals/technology-media/iot-smart-home-domain.js +2 -0
- package/build/src/modeling/templates/verticals/technology-media/iot-smart-home-domain.js.map +1 -1
- package/build/src/modeling/validation/postgresql.d.ts.map +1 -1
- package/build/src/modeling/validation/postgresql.js +0 -1
- package/build/src/modeling/validation/postgresql.js.map +1 -1
- package/build/src/runtime/modeling/Semantics.d.ts +84 -0
- package/build/src/runtime/modeling/Semantics.d.ts.map +1 -0
- package/build/src/runtime/modeling/Semantics.js +124 -0
- package/build/src/runtime/modeling/Semantics.js.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/data/models/example-generator-api.json +8 -8
- package/package.json +1 -1
- package/src/modeling/DomainEntity.ts +27 -5
- package/src/modeling/Semantics.ts +493 -0
- package/src/modeling/definitions/Email.ts +1 -1
- package/src/modeling/definitions/Password.ts +1 -3
- package/src/modeling/helpers/Intelisense.ts +33 -65
- package/src/modeling/templates/meta/blog-publishing-platform.json +1 -1
- package/src/modeling/templates/meta/financial-services-platform.json +1 -1
- package/src/modeling/templates/meta/iot-smart-home-platform.json +1 -1
- package/src/modeling/templates/verticals/business-services/financial-services-domain.ts +285 -65
- package/src/modeling/templates/verticals/technology-media/blog-domain.ts +5 -5
- package/src/modeling/templates/verticals/technology-media/iot-smart-home-domain.ts +2 -0
- package/src/modeling/validation/postgresql.ts +0 -1
- package/src/runtime/modeling/Semantics.ts +196 -0
- package/tests/unit/modeling/client_ip_address_semantic.spec.ts +71 -0
- package/tests/unit/modeling/definitions/password.spec.ts +0 -2
- package/tests/unit/modeling/domain_entity_parents.spec.ts +243 -0
- package/tests/unit/modeling/semantic_runtime.spec.ts +113 -0
- package/tests/unit/modeling/semantics.spec.ts +68 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Jexl } from '@pawel-up/jexl/Jexl.js'
|
|
2
|
+
import type { DomainEntity } from '../../modeling/DomainEntity.js'
|
|
3
|
+
import {
|
|
4
|
+
type AppliedDataSemantic,
|
|
5
|
+
DataSemantics,
|
|
6
|
+
type SemanticCondition,
|
|
7
|
+
SemanticOperation,
|
|
8
|
+
SemanticType,
|
|
9
|
+
} from '../../modeling/Semantics.js'
|
|
10
|
+
import type { TransformFunction } from '@pawel-up/jexl'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cache for JEXL instances to avoid recreation overhead
|
|
14
|
+
*/
|
|
15
|
+
const jexlCache = new Map<string, Jexl>()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get or create a JEXL instance with optional custom transforms
|
|
19
|
+
*/
|
|
20
|
+
export function getJexlInstance(transforms?: Record<string, TransformFunction>): Jexl {
|
|
21
|
+
const cacheKey = transforms ? JSON.stringify(Object.keys(transforms).sort()) : 'default'
|
|
22
|
+
|
|
23
|
+
if (!jexlCache.has(cacheKey)) {
|
|
24
|
+
const jexl = new Jexl()
|
|
25
|
+
if (transforms) {
|
|
26
|
+
Object.entries(transforms).forEach(([name, fn]) => {
|
|
27
|
+
jexl.addTransform(name, fn)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
jexlCache.set(cacheKey, jexl)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return jexlCache.get(cacheKey) as Jexl
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Context object passed to semantic condition evaluation
|
|
38
|
+
*/
|
|
39
|
+
export interface SemanticExecutionContext {
|
|
40
|
+
/**
|
|
41
|
+
* The entity data being processed
|
|
42
|
+
*/
|
|
43
|
+
entity: Record<string, unknown>
|
|
44
|
+
/**
|
|
45
|
+
* Current user context (if authenticated)
|
|
46
|
+
*/
|
|
47
|
+
user?: {
|
|
48
|
+
id?: string
|
|
49
|
+
authenticated?: boolean
|
|
50
|
+
roles?: string[]
|
|
51
|
+
[key: string]: unknown
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The current database operation
|
|
55
|
+
*/
|
|
56
|
+
operation: SemanticOperation
|
|
57
|
+
/**
|
|
58
|
+
* Applied semantics with their field mappings
|
|
59
|
+
*/
|
|
60
|
+
appliedSemantics: AppliedDataSemantic[]
|
|
61
|
+
/**
|
|
62
|
+
* Configuration for the current semantic being evaluated
|
|
63
|
+
*/
|
|
64
|
+
config?: Record<string, unknown>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds a semantics object for JEXL evaluation that maps semantic types to actual field names.
|
|
69
|
+
*
|
|
70
|
+
* The resulting map should be used in JEXL expressions to reference semantic fields.
|
|
71
|
+
*
|
|
72
|
+
* @returns A map where keys are semantic type identifiers and values are the field names.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const semanticFieldMap = buildSemanticFieldMap(entity);
|
|
76
|
+
* const result = await jexl.eval("semantics.CreatedTimestamp == null", {
|
|
77
|
+
* semantics: semanticFieldMap,
|
|
78
|
+
* ...
|
|
79
|
+
* }
|
|
80
|
+
*/
|
|
81
|
+
export function buildSemanticFieldMap(entity: DomainEntity): Record<string, string> {
|
|
82
|
+
const semanticFieldMap: Record<string, string> = {}
|
|
83
|
+
|
|
84
|
+
for (const property of entity.properties) {
|
|
85
|
+
if (!property.info.name) {
|
|
86
|
+
continue // Skip properties without a name
|
|
87
|
+
}
|
|
88
|
+
for (const semantic of property.semantics) {
|
|
89
|
+
// We use the truncated `semantic.id` as the key, e.g. 'Password' so that it is possible to do something like:
|
|
90
|
+
// `entity[semantics.Password] != null && !entity[semantics.Password].startsWith('$')`
|
|
91
|
+
// Where the `semantics` object is the object returned by this function and
|
|
92
|
+
// passed to the JEXL context.
|
|
93
|
+
const semanticKey = semantic.id.replace('Semantic#', '')
|
|
94
|
+
semanticFieldMap[semanticKey] = property.info.name
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return semanticFieldMap
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Evaluates a semantic condition against the execution context
|
|
102
|
+
* In a real implementation, this would use JEXL to evaluate the expression
|
|
103
|
+
*
|
|
104
|
+
* @param condition The semantic condition to evaluate
|
|
105
|
+
* @param context The execution context containing entity data, user info, etc.
|
|
106
|
+
* @param semanticFieldMap A map of semantic field names to their actual field names.
|
|
107
|
+
* Use the `buildSemanticFieldMap()` function to create this map.
|
|
108
|
+
* @param jexl Optional JEXL instance to use for evaluation, defaults to a cached instance.
|
|
109
|
+
* @returns A promise that resolves to true if the condition is met, false otherwise
|
|
110
|
+
*/
|
|
111
|
+
export function evaluateSemanticCondition(
|
|
112
|
+
condition: SemanticCondition,
|
|
113
|
+
context: SemanticExecutionContext,
|
|
114
|
+
semanticFieldMap: Record<string, string>,
|
|
115
|
+
jexl: Jexl = getJexlInstance()
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
// Build the evaluation context
|
|
118
|
+
const evalContext = {
|
|
119
|
+
entity: context.entity,
|
|
120
|
+
user: context.user,
|
|
121
|
+
operation: context.operation,
|
|
122
|
+
config: context.config,
|
|
123
|
+
semantics: semanticFieldMap,
|
|
124
|
+
}
|
|
125
|
+
return jexl.eval(condition.expression, evalContext)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if semantic should execute based on all its conditions
|
|
130
|
+
*
|
|
131
|
+
* @param semantic The semantic to check
|
|
132
|
+
* @param context The execution context containing entity data, user info, etc.
|
|
133
|
+
* @param semanticFieldMap A map of semantic field names to their actual field names.
|
|
134
|
+
* Use the `buildSemanticFieldMap()` function to create this map.
|
|
135
|
+
* @return A promise that resolves to true if all conditions are met, false otherwise
|
|
136
|
+
*/
|
|
137
|
+
export async function shouldSemanticExecute(
|
|
138
|
+
semantic: AppliedDataSemantic,
|
|
139
|
+
context: SemanticExecutionContext,
|
|
140
|
+
semanticFieldMap: Record<string, string>
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
const definition = DataSemantics[semantic.id]
|
|
143
|
+
const conditions = definition?.runtime?.conditions
|
|
144
|
+
|
|
145
|
+
if (!conditions || conditions.length === 0) {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const results = await Promise.all(
|
|
150
|
+
conditions.map((condition) => evaluateSemanticCondition(condition, context, semanticFieldMap))
|
|
151
|
+
)
|
|
152
|
+
// All conditions must evaluate to true
|
|
153
|
+
return results.every((result) => result === true)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Helper to get the actual field name for a semantic type
|
|
158
|
+
*/
|
|
159
|
+
export function getFieldNameForSemantic(
|
|
160
|
+
semanticType: SemanticType,
|
|
161
|
+
semanticFieldMap: Record<string, string>
|
|
162
|
+
): string | undefined {
|
|
163
|
+
const semanticKey = semanticType.replace('Semantic#', '')
|
|
164
|
+
return semanticFieldMap[semanticKey]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Validates that all semantic references in conditions are available
|
|
169
|
+
*/
|
|
170
|
+
export function validateSemanticConditions(
|
|
171
|
+
semantic: AppliedDataSemantic,
|
|
172
|
+
semanticFieldMap: Record<string, string>
|
|
173
|
+
): string[] {
|
|
174
|
+
const definition = DataSemantics[semantic.id]
|
|
175
|
+
const conditions = definition?.runtime?.conditions || []
|
|
176
|
+
const errors: string[] = []
|
|
177
|
+
|
|
178
|
+
conditions.forEach((condition, index) => {
|
|
179
|
+
// Extract semantic references from the condition expression
|
|
180
|
+
// Look for patterns like "semantics.CreatedTimestamp" or "entity[semantics.Password]"
|
|
181
|
+
const semanticMatches = condition.expression.match(/semantics\.(\w+)/g)
|
|
182
|
+
|
|
183
|
+
if (semanticMatches) {
|
|
184
|
+
semanticMatches.forEach((match) => {
|
|
185
|
+
const semanticKey = match.replace('semantics.', '')
|
|
186
|
+
if (!semanticFieldMap[semanticKey]) {
|
|
187
|
+
errors.push(
|
|
188
|
+
`Condition ${index + 1} references semantic "${semanticKey}" but no field with that semantic was found`
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return errors
|
|
196
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import {
|
|
3
|
+
SemanticType,
|
|
4
|
+
DataSemantics,
|
|
5
|
+
isPropertySemantic,
|
|
6
|
+
SemanticCategory,
|
|
7
|
+
SemanticScope,
|
|
8
|
+
SemanticTiming,
|
|
9
|
+
SemanticOperation,
|
|
10
|
+
} from '../../../src/modeling/Semantics.js'
|
|
11
|
+
|
|
12
|
+
test.group('ClientIPAddress Semantic', () => {
|
|
13
|
+
test('should exist in SemanticType enum', ({ assert }) => {
|
|
14
|
+
assert.equal(SemanticType.ClientIPAddress, 'Semantic#ClientIPAddress')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('should exist in DataSemantics registry', ({ assert }) => {
|
|
18
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
19
|
+
assert.isDefined(semantic)
|
|
20
|
+
assert.equal(semantic.id, SemanticType.ClientIPAddress)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('should be a property semantic', ({ assert }) => {
|
|
24
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
25
|
+
assert.isTrue(isPropertySemantic(semantic))
|
|
26
|
+
assert.equal(semantic.scope, SemanticScope.Property)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('should have correct display name and description', ({ assert }) => {
|
|
30
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
31
|
+
assert.equal(semantic.displayName, 'Client IP Address')
|
|
32
|
+
assert.equal(semantic.description, 'Automatically populated client IP address')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('should be in Contact category', ({ assert }) => {
|
|
36
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
37
|
+
assert.equal(semantic.category, SemanticCategory.Contact)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('should only apply to string data types', ({ assert }) => {
|
|
41
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
42
|
+
if (isPropertySemantic(semantic)) {
|
|
43
|
+
assert.deepEqual(semantic.applicableDataTypes, ['string'])
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('should not have configuration', ({ assert }) => {
|
|
48
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
49
|
+
assert.isFalse(semantic.hasConfig)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('should have correct runtime configuration', ({ assert }) => {
|
|
53
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
54
|
+
const runtime = semantic.runtime
|
|
55
|
+
|
|
56
|
+
assert.equal(runtime.timing, SemanticTiming.Before)
|
|
57
|
+
assert.deepEqual(runtime.operations, [SemanticOperation.Create, SemanticOperation.Update])
|
|
58
|
+
assert.equal(runtime.priority, 95) // Low priority, populate after other validations
|
|
59
|
+
assert.equal(runtime.timeoutMs, 100) // Very fast operation
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('should have correct execution conditions', ({ assert }) => {
|
|
63
|
+
const semantic = DataSemantics[SemanticType.ClientIPAddress]
|
|
64
|
+
const conditions = semantic.runtime.conditions
|
|
65
|
+
|
|
66
|
+
assert.isDefined(conditions)
|
|
67
|
+
assert.lengthOf(conditions!, 1)
|
|
68
|
+
assert.equal(conditions![0].expression, 'entity[semantics.ClientIPAddress] == null')
|
|
69
|
+
assert.equal(conditions![0].description, 'Only set IP address if not already provided')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -77,8 +77,6 @@ test.group('Password Semantic Configuration', () => {
|
|
|
77
77
|
assert.equal(DEFAULT_PASSWORD_CONFIG.requireNumbers, true)
|
|
78
78
|
assert.equal(DEFAULT_PASSWORD_CONFIG.requireUppercase, true)
|
|
79
79
|
assert.equal(DEFAULT_PASSWORD_CONFIG.requireLowercase, true)
|
|
80
|
-
assert.equal(DEFAULT_PASSWORD_CONFIG.encryptionAlgorithm, PasswordEncryptionAlgorithm.Bcrypt)
|
|
81
|
-
assert.equal(DEFAULT_PASSWORD_CONFIG.saltRounds, 12)
|
|
82
80
|
assert.equal(DEFAULT_PASSWORD_CONFIG.maxAge, undefined)
|
|
83
81
|
assert.equal(DEFAULT_PASSWORD_CONFIG.preventReuse, false)
|
|
84
82
|
assert.equal(DEFAULT_PASSWORD_CONFIG.preventReuseCount, 5)
|
|
@@ -141,6 +141,101 @@ test.group('DomainEntity.addParent()', () => {
|
|
|
141
141
|
assert.isTrue(dataDomain.graph.hasEdge(entity2.key, entity1.key))
|
|
142
142
|
assert.deepEqual(dataDomain.graph.edge(entity2.key, entity1.key), { type: 'parent' })
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
test('adds a foreign domain parent to the entity', ({ assert }) => {
|
|
146
|
+
const dataDomain = new DataDomain()
|
|
147
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
148
|
+
const model = dataDomain.addModel()
|
|
149
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
150
|
+
const foreignModel = foreignDomain.addModel()
|
|
151
|
+
const foreignEntity = foreignModel.addEntity({ key: 'parent-entity' })
|
|
152
|
+
const foreignKey = `${foreignDomain.key}:${foreignEntity.key}`
|
|
153
|
+
|
|
154
|
+
// Register foreign domain and add foreign parent
|
|
155
|
+
foreignDomain.info.version = '1.0.0'
|
|
156
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
157
|
+
entity.addParent('parent-entity', 'external-domain')
|
|
158
|
+
|
|
159
|
+
// Verify it was added correctly
|
|
160
|
+
assert.isTrue(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
161
|
+
assert.deepEqual(dataDomain.graph.edge(entity.key, foreignKey), {
|
|
162
|
+
type: 'parent',
|
|
163
|
+
domain: 'external-domain',
|
|
164
|
+
foreign: true,
|
|
165
|
+
})
|
|
166
|
+
const parents = [...entity.listParents()]
|
|
167
|
+
assert.lengthOf(parents, 1)
|
|
168
|
+
assert.deepEqual(parents[0], foreignEntity)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('throws an error if foreign domain parent does not exist', ({ assert }) => {
|
|
172
|
+
const dataDomain = new DataDomain()
|
|
173
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
174
|
+
const model = dataDomain.addModel()
|
|
175
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
176
|
+
|
|
177
|
+
// Register foreign domain without the parent entity
|
|
178
|
+
foreignDomain.info.version = '1.0.0'
|
|
179
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
180
|
+
|
|
181
|
+
assert.throws(() => {
|
|
182
|
+
entity.addParent('non-existent-parent', 'external-domain')
|
|
183
|
+
}, 'Entity with key "non-existent-parent" not found in domain "external-domain"')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('throws an error if foreign domain is not registered', ({ assert }) => {
|
|
187
|
+
const dataDomain = new DataDomain()
|
|
188
|
+
const model = dataDomain.addModel()
|
|
189
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
190
|
+
|
|
191
|
+
assert.throws(() => {
|
|
192
|
+
entity.addParent('parent-entity', 'non-existent-domain')
|
|
193
|
+
}, 'Foreign domain non-existent-domain not found')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('can add both local and foreign parents', ({ assert }) => {
|
|
197
|
+
const dataDomain = new DataDomain()
|
|
198
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
199
|
+
const model = dataDomain.addModel()
|
|
200
|
+
const localParent = model.addEntity({ key: 'local-parent' })
|
|
201
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
202
|
+
const foreignModel = foreignDomain.addModel()
|
|
203
|
+
const foreignEntity = foreignModel.addEntity({ key: 'foreign-parent' })
|
|
204
|
+
const foreignKey = `${foreignDomain.key}:${foreignEntity.key}`
|
|
205
|
+
|
|
206
|
+
// Register foreign domain and add both parents
|
|
207
|
+
foreignDomain.info.version = '1.0.0'
|
|
208
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
209
|
+
entity.addParent(localParent.key)
|
|
210
|
+
entity.addParent('foreign-parent', 'external-domain')
|
|
211
|
+
|
|
212
|
+
// Verify both parents were added
|
|
213
|
+
assert.isTrue(dataDomain.graph.hasEdge(entity.key, localParent.key))
|
|
214
|
+
assert.isTrue(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
215
|
+
|
|
216
|
+
const parents = [...entity.listParents()]
|
|
217
|
+
assert.lengthOf(parents, 2)
|
|
218
|
+
assert.deepInclude(parents, localParent)
|
|
219
|
+
assert.deepInclude(parents, foreignEntity)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('throws an error if trying to add the same foreign parent twice', ({ assert }) => {
|
|
223
|
+
const dataDomain = new DataDomain()
|
|
224
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
225
|
+
const model = dataDomain.addModel()
|
|
226
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
227
|
+
const foreignModel = foreignDomain.addModel()
|
|
228
|
+
foreignModel.addEntity({ key: 'parent-entity' })
|
|
229
|
+
|
|
230
|
+
// Register foreign domain and add foreign parent
|
|
231
|
+
foreignDomain.info.version = '1.0.0'
|
|
232
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
233
|
+
entity.addParent('parent-entity', 'external-domain')
|
|
234
|
+
|
|
235
|
+
assert.throws(() => {
|
|
236
|
+
entity.addParent('parent-entity', 'external-domain')
|
|
237
|
+
}, 'Parent parent-entity already exists')
|
|
238
|
+
})
|
|
144
239
|
})
|
|
145
240
|
|
|
146
241
|
test.group('DomainEntity.removeParent()', () => {
|
|
@@ -164,6 +259,121 @@ test.group('DomainEntity.removeParent()', () => {
|
|
|
164
259
|
}, `Trying to remove a parent non-existent-entity from ${entity.key}, but it doesn't exist`)
|
|
165
260
|
})
|
|
166
261
|
|
|
262
|
+
test('throws an error if foreign domain parent does not exist', ({ assert }) => {
|
|
263
|
+
const dataDomain = new DataDomain()
|
|
264
|
+
const model = dataDomain.addModel()
|
|
265
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
266
|
+
assert.throws(() => {
|
|
267
|
+
entity.removeParent('non-existent-entity', 'external-domain')
|
|
268
|
+
}, `Foreign domain external-domain not found`)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('throws an error if edge exists but is not a parent relationship', ({ assert }) => {
|
|
272
|
+
const dataDomain = new DataDomain()
|
|
273
|
+
const model = dataDomain.addModel()
|
|
274
|
+
const entity1 = model.addEntity({ key: 'entity1' })
|
|
275
|
+
const entity2 = model.addEntity({ key: 'entity2' })
|
|
276
|
+
// Create a non-parent edge
|
|
277
|
+
dataDomain.graph.setEdge(entity2.key, entity1.key, { type: 'association' })
|
|
278
|
+
assert.throws(() => {
|
|
279
|
+
entity2.removeParent(entity1.key)
|
|
280
|
+
}, `Edge between ${entity2.key} and entity1 exists but is not a parent relationship`)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('throws an error if foreign domain edge exists but is not a parent relationship', ({ assert }) => {
|
|
284
|
+
const dataDomain = new DataDomain()
|
|
285
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
286
|
+
const model = dataDomain.addModel()
|
|
287
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
288
|
+
const foreignModel = foreignDomain.addModel()
|
|
289
|
+
const foreignEntity = foreignModel.addEntity({ key: 'parent-entity' })
|
|
290
|
+
|
|
291
|
+
// Register foreign domain and create a non-parent edge
|
|
292
|
+
foreignDomain.info.version = '1.0.0'
|
|
293
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
294
|
+
const foreignKey = `${foreignDomain.key}:${foreignEntity.key}`
|
|
295
|
+
dataDomain.graph.setEdge(entity.key, foreignKey, { type: 'association' })
|
|
296
|
+
|
|
297
|
+
assert.throws(() => {
|
|
298
|
+
entity.removeParent('parent-entity', 'external-domain')
|
|
299
|
+
}, `Edge between child-entity and parent-entity in domain "external-domain" exists but is not a parent relationship`)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('removes a foreign domain parent from the entity', ({ assert }) => {
|
|
303
|
+
const dataDomain = new DataDomain()
|
|
304
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
305
|
+
const model = dataDomain.addModel()
|
|
306
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
307
|
+
const foreignModel = foreignDomain.addModel()
|
|
308
|
+
const foreignEntity = foreignModel.addEntity({ key: 'parent-entity' })
|
|
309
|
+
const foreignKey = `${foreignDomain.key}:${foreignEntity.key}`
|
|
310
|
+
|
|
311
|
+
// Register foreign domain and add foreign parent
|
|
312
|
+
foreignDomain.info.version = '1.0.0'
|
|
313
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
314
|
+
entity.addParent('parent-entity', 'external-domain')
|
|
315
|
+
|
|
316
|
+
// Verify it was added
|
|
317
|
+
assert.isTrue(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
318
|
+
assert.deepEqual(dataDomain.graph.edge(entity.key, foreignKey), {
|
|
319
|
+
type: 'parent',
|
|
320
|
+
domain: 'external-domain',
|
|
321
|
+
foreign: true,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// Remove the foreign parent
|
|
325
|
+
entity.removeParent('parent-entity', 'external-domain')
|
|
326
|
+
assert.isFalse(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('removes local parent while keeping foreign parent', ({ assert }) => {
|
|
330
|
+
const dataDomain = new DataDomain()
|
|
331
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
332
|
+
const model = dataDomain.addModel()
|
|
333
|
+
const localParent = model.addEntity({ key: 'local-parent' })
|
|
334
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
335
|
+
const foreignModel = foreignDomain.addModel()
|
|
336
|
+
const foreignEntity = foreignModel.addEntity({ key: 'foreign-parent' })
|
|
337
|
+
const foreignKey = `${foreignDomain.key}:${foreignEntity.key}`
|
|
338
|
+
|
|
339
|
+
// Register foreign domain and add both parents
|
|
340
|
+
foreignDomain.info.version = '1.0.0'
|
|
341
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
342
|
+
entity.addParent(localParent.key)
|
|
343
|
+
entity.addParent('foreign-parent', 'external-domain')
|
|
344
|
+
|
|
345
|
+
// Remove only the local parent
|
|
346
|
+
entity.removeParent(localParent.key)
|
|
347
|
+
|
|
348
|
+
// Verify local parent is removed but foreign remains
|
|
349
|
+
assert.isFalse(dataDomain.graph.hasEdge(entity.key, localParent.key))
|
|
350
|
+
assert.isTrue(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('removes foreign parent while keeping local parent', ({ assert }) => {
|
|
354
|
+
const dataDomain = new DataDomain()
|
|
355
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
356
|
+
const model = dataDomain.addModel()
|
|
357
|
+
const localParent = model.addEntity({ key: 'local-parent' })
|
|
358
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
359
|
+
const foreignModel = foreignDomain.addModel()
|
|
360
|
+
const foreignEntity = foreignModel.addEntity({ key: 'foreign-parent' })
|
|
361
|
+
const foreignKey = `${foreignDomain.key}:${foreignEntity.key}`
|
|
362
|
+
|
|
363
|
+
// Register foreign domain and add both parents
|
|
364
|
+
foreignDomain.info.version = '1.0.0'
|
|
365
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
366
|
+
entity.addParent(localParent.key)
|
|
367
|
+
entity.addParent('foreign-parent', 'external-domain')
|
|
368
|
+
|
|
369
|
+
// Remove only the foreign parent
|
|
370
|
+
entity.removeParent('foreign-parent', 'external-domain')
|
|
371
|
+
|
|
372
|
+
// Verify foreign parent is removed but local remains
|
|
373
|
+
assert.isTrue(dataDomain.graph.hasEdge(entity.key, localParent.key))
|
|
374
|
+
assert.isFalse(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
375
|
+
})
|
|
376
|
+
|
|
167
377
|
test('notifies change', async ({ assert }) => {
|
|
168
378
|
const dataDomain = new DataDomain()
|
|
169
379
|
const model = dataDomain.addModel()
|
|
@@ -174,6 +384,22 @@ test.group('DomainEntity.removeParent()', () => {
|
|
|
174
384
|
await assert.dispatches(dataDomain, 'change', { timeout: 20 })
|
|
175
385
|
})
|
|
176
386
|
|
|
387
|
+
test('notifies change when removing foreign domain parent', async ({ assert }) => {
|
|
388
|
+
const dataDomain = new DataDomain()
|
|
389
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
390
|
+
const model = dataDomain.addModel()
|
|
391
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
392
|
+
const foreignModel = foreignDomain.addModel()
|
|
393
|
+
foreignModel.addEntity({ key: 'parent-entity' })
|
|
394
|
+
|
|
395
|
+
// Register foreign domain and add foreign parent
|
|
396
|
+
foreignDomain.info.version = '1.0.0'
|
|
397
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
398
|
+
entity.addParent('parent-entity', 'external-domain')
|
|
399
|
+
entity.removeParent('parent-entity', 'external-domain')
|
|
400
|
+
await assert.dispatches(dataDomain, 'change', { timeout: 20 })
|
|
401
|
+
})
|
|
402
|
+
|
|
177
403
|
test('removes the graph edge', ({ assert }) => {
|
|
178
404
|
const dataDomain = new DataDomain()
|
|
179
405
|
const model = dataDomain.addModel()
|
|
@@ -183,6 +409,23 @@ test.group('DomainEntity.removeParent()', () => {
|
|
|
183
409
|
entity2.removeParent(entity1.key)
|
|
184
410
|
assert.isFalse(dataDomain.graph.hasEdge(entity2.key, entity1.key))
|
|
185
411
|
})
|
|
412
|
+
|
|
413
|
+
test('removes the foreign domain graph edge', ({ assert }) => {
|
|
414
|
+
const dataDomain = new DataDomain()
|
|
415
|
+
const foreignDomain = new DataDomain({ key: 'external-domain' })
|
|
416
|
+
const model = dataDomain.addModel()
|
|
417
|
+
const entity = model.addEntity({ key: 'child-entity' })
|
|
418
|
+
const foreignModel = foreignDomain.addModel()
|
|
419
|
+
foreignModel.addEntity({ key: 'parent-entity' })
|
|
420
|
+
const foreignKey = `${foreignDomain.key}:parent-entity`
|
|
421
|
+
|
|
422
|
+
// Register foreign domain and add foreign parent
|
|
423
|
+
foreignDomain.info.version = '1.0.0'
|
|
424
|
+
dataDomain.registerForeignDomain(foreignDomain)
|
|
425
|
+
entity.addParent('parent-entity', 'external-domain')
|
|
426
|
+
entity.removeParent('parent-entity', 'external-domain')
|
|
427
|
+
assert.isFalse(dataDomain.graph.hasEdge(entity.key, foreignKey))
|
|
428
|
+
})
|
|
186
429
|
})
|
|
187
430
|
|
|
188
431
|
test.group('DomainEntity.hasCircularParent()', () => {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import {
|
|
3
|
+
SemanticType,
|
|
4
|
+
SemanticTiming,
|
|
5
|
+
SemanticOperation,
|
|
6
|
+
getSemanticsForOperation,
|
|
7
|
+
canSemanticBeDisabled,
|
|
8
|
+
type AppliedDataSemantic,
|
|
9
|
+
} from '../../../src/modeling/Semantics.js'
|
|
10
|
+
|
|
11
|
+
test.group('Semantic Runtime Control', () => {
|
|
12
|
+
test('getSemanticsForOperation should filter semantics by operation and timing', ({ assert }) => {
|
|
13
|
+
const semantics: AppliedDataSemantic[] = [
|
|
14
|
+
{ id: SemanticType.Password },
|
|
15
|
+
{ id: SemanticType.CreatedTimestamp },
|
|
16
|
+
{ id: SemanticType.UpdatedTimestamp },
|
|
17
|
+
{ id: SemanticType.Status },
|
|
18
|
+
{ id: SemanticType.Title },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Test CREATE operation with BEFORE timing
|
|
22
|
+
const createBeforeSemantics = getSemanticsForOperation(semantics, SemanticOperation.Create, SemanticTiming.Before)
|
|
23
|
+
const createBeforeTypes = createBeforeSemantics.map((s) => s.id)
|
|
24
|
+
|
|
25
|
+
assert.includeMembers(createBeforeTypes, [SemanticType.Password, SemanticType.CreatedTimestamp])
|
|
26
|
+
assert.notInclude(createBeforeTypes, SemanticType.UpdatedTimestamp) // Only runs on UPDATE
|
|
27
|
+
assert.notInclude(createBeforeTypes, SemanticType.Title) // No runtime operations
|
|
28
|
+
|
|
29
|
+
// Test UPDATE operation with BEFORE timing
|
|
30
|
+
const updateBeforeSemantics = getSemanticsForOperation(semantics, SemanticOperation.Update, SemanticTiming.Before)
|
|
31
|
+
const updateBeforeTypes = updateBeforeSemantics.map((s) => s.id)
|
|
32
|
+
|
|
33
|
+
assert.includeMembers(updateBeforeTypes, [SemanticType.Password, SemanticType.UpdatedTimestamp])
|
|
34
|
+
assert.notInclude(updateBeforeTypes, SemanticType.CreatedTimestamp) // Only runs on CREATE
|
|
35
|
+
|
|
36
|
+
// Test Status semantic runs on both CREATE and UPDATE
|
|
37
|
+
assert.include(createBeforeTypes, SemanticType.Status)
|
|
38
|
+
assert.include(updateBeforeTypes, SemanticType.Status)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('getSemanticsForOperation should sort semantics by priority', ({ assert }) => {
|
|
42
|
+
const semantics: AppliedDataSemantic[] = [
|
|
43
|
+
{ id: SemanticType.Password }, // priority: 10
|
|
44
|
+
{ id: SemanticType.ResourceOwnerIdentifier }, // priority: 5
|
|
45
|
+
{ id: SemanticType.UserRole }, // priority: 20
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const createSemantics = getSemanticsForOperation(semantics, SemanticOperation.Create, SemanticTiming.Before)
|
|
49
|
+
|
|
50
|
+
// Should be sorted by priority: ResourceOwnerIdentifier (5), Password (10), UserRole (20)
|
|
51
|
+
assert.equal(createSemantics[0].id, SemanticType.ResourceOwnerIdentifier)
|
|
52
|
+
assert.equal(createSemantics[1].id, SemanticType.Password)
|
|
53
|
+
assert.equal(createSemantics[2].id, SemanticType.UserRole)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('getSemanticsForOperation should handle BOTH timing correctly', ({ assert }) => {
|
|
57
|
+
const semantics: AppliedDataSemantic[] = [
|
|
58
|
+
{ id: SemanticType.Status }, // timing: Both
|
|
59
|
+
{ id: SemanticType.Password }, // timing: Before
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const beforeSemantics = getSemanticsForOperation(semantics, SemanticOperation.Create, SemanticTiming.Before)
|
|
63
|
+
const afterSemantics = getSemanticsForOperation(semantics, SemanticOperation.Create, SemanticTiming.After)
|
|
64
|
+
|
|
65
|
+
// Status should appear in both BEFORE and AFTER
|
|
66
|
+
assert.equal(beforeSemantics.length, 2) // Status and Password
|
|
67
|
+
assert.equal(afterSemantics.length, 1) // Only Status
|
|
68
|
+
assert.equal(afterSemantics[0].id, SemanticType.Status)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('canSemanticBeDisabled should check if semantic can be disabled', ({ assert }) => {
|
|
72
|
+
// Security semantics cannot be disabled
|
|
73
|
+
assert.isFalse(canSemanticBeDisabled(SemanticType.Password))
|
|
74
|
+
assert.isFalse(canSemanticBeDisabled(SemanticType.ResourceOwnerIdentifier))
|
|
75
|
+
|
|
76
|
+
// Other semantics can be disabled (default)
|
|
77
|
+
assert.isTrue(canSemanticBeDisabled(SemanticType.CreatedTimestamp))
|
|
78
|
+
assert.isTrue(canSemanticBeDisabled(SemanticType.Email))
|
|
79
|
+
assert.isTrue(canSemanticBeDisabled(SemanticType.Status))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('getSemanticsForOperation should return empty array for semantics with no runtime', ({ assert }) => {
|
|
83
|
+
const semantics: AppliedDataSemantic[] = [
|
|
84
|
+
{ id: SemanticType.Title }, // No runtime operations
|
|
85
|
+
{ id: SemanticType.Description }, // No runtime operations
|
|
86
|
+
{ id: SemanticType.User }, // No runtime operations
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const result = getSemanticsForOperation(semantics, SemanticOperation.Create, SemanticTiming.Before)
|
|
90
|
+
assert.lengthOf(result, 0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('getSemanticsForOperation should filter by specific operations', ({ assert }) => {
|
|
94
|
+
const semantics: AppliedDataSemantic[] = [
|
|
95
|
+
{ id: SemanticType.CreatedTimestamp }, // Only CREATE
|
|
96
|
+
{ id: SemanticType.UpdatedTimestamp }, // Only UPDATE
|
|
97
|
+
{ id: SemanticType.DeletedTimestamp }, // Only DELETE
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
const createSemantics = getSemanticsForOperation(semantics, SemanticOperation.Create, SemanticTiming.Before)
|
|
101
|
+
const updateSemantics = getSemanticsForOperation(semantics, SemanticOperation.Update, SemanticTiming.Before)
|
|
102
|
+
const deleteSemantics = getSemanticsForOperation(semantics, SemanticOperation.Delete, SemanticTiming.Before)
|
|
103
|
+
|
|
104
|
+
assert.lengthOf(createSemantics, 1)
|
|
105
|
+
assert.equal(createSemantics[0].id, SemanticType.CreatedTimestamp)
|
|
106
|
+
|
|
107
|
+
assert.lengthOf(updateSemantics, 1)
|
|
108
|
+
assert.equal(updateSemantics[0].id, SemanticType.UpdatedTimestamp)
|
|
109
|
+
|
|
110
|
+
assert.lengthOf(deleteSemantics, 1)
|
|
111
|
+
assert.equal(deleteSemantics[0].id, SemanticType.DeletedTimestamp)
|
|
112
|
+
})
|
|
113
|
+
})
|