Passed
Pull Request — master (#2607)
by Tolga
03:23
created

coverage.extractRelationships   A

Complexity

Conditions 3

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 11
nop 1
dl 0
loc 16
rs 9.85
c 0
b 0
f 0
1
package coverage
2
3
import (
4
	"fmt"
5
	"slices"
6
7
	"github.com/Permify/permify/pkg/attribute"
8
	"github.com/Permify/permify/pkg/development/file"
9
	"github.com/Permify/permify/pkg/dsl/compiler"
10
	"github.com/Permify/permify/pkg/dsl/parser"
11
	base "github.com/Permify/permify/pkg/pb/base/v1"
12
	"github.com/Permify/permify/pkg/tuple"
13
)
14
15
// SchemaCoverageInfo represents the overall coverage information for a schema
16
type SchemaCoverageInfo struct {
17
	EntityCoverageInfo         []EntityCoverageInfo
18
	TotalRelationshipsCoverage int
19
	TotalAttributesCoverage    int
20
	TotalAssertionsCoverage    int
21
}
22
23
// EntityCoverageInfo represents coverage information for a single entity
24
type EntityCoverageInfo struct {
25
	EntityName string
26
27
	UncoveredRelationships       []string
28
	CoverageRelationshipsPercent int
29
30
	UncoveredAttributes       []string
31
	CoverageAttributesPercent int
32
33
	UncoveredAssertions       map[string][]string
34
	CoverageAssertionsPercent map[string]int
35
}
36
37
// SchemaCoverage represents the expected coverage for a schema entity
38
//
39
// Example schema:
40
//
41
//	entity user {}
42
//
43
//	entity organization {
44
//	    relation admin @user
45
//	    relation member @user
46
//	}
47
//
48
//	entity repository {
49
//	    relation parent @organization
50
//	    relation owner  @user @organization#admin
51
//	    permission edit   = parent.admin or owner
52
//	    permission delete = owner
53
//	}
54
//
55
// Expected relationships coverage:
56
//   - organization#admin@user
57
//   - organization#member@user
58
//   - repository#parent@organization
59
//   - repository#owner@user
60
//   - repository#owner@organization#admin
61
//
62
// Expected assertions coverage:
63
//   - repository#edit
64
//   - repository#delete
65
type SchemaCoverage struct {
66
	EntityName    string
67
	Relationships []string
68
	Attributes    []string
69
	Assertions    []string
70
}
71
72
// Run analyzes the coverage of relationships, attributes, and assertions
73
// for a given schema shape and returns the coverage information
74
func Run(shape file.Shape) SchemaCoverageInfo {
75
	definitions, err := parseAndCompileSchema(shape.Schema)
76
	if err != nil {
77
		return SchemaCoverageInfo{}
78
	}
79
80
	refs := extractSchemaReferences(definitions)
81
	entityCoverageInfos := calculateEntityCoverages(refs, shape)
82
83
	return buildSchemaCoverageInfo(entityCoverageInfos)
84
}
85
86
// parseAndCompileSchema parses and compiles the schema into entity definitions
87
func parseAndCompileSchema(schema string) ([]*base.EntityDefinition, error) {
88
	p, err := parser.NewParser(schema).Parse()
89
	if err != nil {
90
		return nil, err
91
	}
92
93
	definitions, _, err := compiler.NewCompiler(true, p).Compile()
94
	if err != nil {
95
		return nil, err
96
	}
97
98
	return definitions, nil
99
}
100
101
// extractSchemaReferences extracts all coverage references from entity definitions
102
func extractSchemaReferences(definitions []*base.EntityDefinition) []SchemaCoverage {
103
	refs := make([]SchemaCoverage, len(definitions))
104
	for idx, entityDef := range definitions {
105
		refs[idx] = extractEntityReferences(entityDef)
106
	}
107
	return refs
108
}
109
110
// extractEntityReferences extracts relationships, attributes, and assertions from an entity definition
111
func extractEntityReferences(entity *base.EntityDefinition) SchemaCoverage {
112
	coverage := SchemaCoverage{
113
		EntityName:    entity.GetName(),
114
		Relationships: extractRelationships(entity),
115
		Attributes:    extractAttributes(entity),
116
		Assertions:    extractAssertions(entity),
117
	}
118
	return coverage
119
}
120
121
// extractRelationships extracts all relationship references from an entity
122
func extractRelationships(entity *base.EntityDefinition) []string {
123
	relationships := []string{}
124
125
	for _, relation := range entity.GetRelations() {
126
		for _, reference := range relation.GetRelationReferences() {
127
			formatted := formatRelationship(
128
				entity.GetName(),
129
				relation.GetName(),
130
				reference.GetType(),
131
				reference.GetRelation(),
132
			)
133
			relationships = append(relationships, formatted)
134
		}
135
	}
136
137
	return relationships
138
}
139
140
// extractAttributes extracts all attribute references from an entity
141
func extractAttributes(entity *base.EntityDefinition) []string {
142
	attributes := []string{}
143
144
	for _, attr := range entity.GetAttributes() {
145
		formatted := formatAttribute(entity.GetName(), attr.GetName())
146
		attributes = append(attributes, formatted)
147
	}
148
149
	return attributes
150
}
151
152
// extractAssertions extracts all permission/assertion references from an entity
153
func extractAssertions(entity *base.EntityDefinition) []string {
154
	assertions := []string{}
155
156
	for _, permission := range entity.GetPermissions() {
157
		formatted := formatAssertion(entity.GetName(), permission.GetName())
158
		assertions = append(assertions, formatted)
159
	}
160
161
	return assertions
162
}
163
164
// calculateEntityCoverages calculates coverage for all entities
165
func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape) []EntityCoverageInfo {
166
	entityCoverageInfos := []EntityCoverageInfo{}
167
168
	for _, ref := range refs {
169
		entityCoverageInfo := calculateEntityCoverage(ref, shape)
170
		entityCoverageInfos = append(entityCoverageInfos, entityCoverageInfo)
171
	}
172
173
	return entityCoverageInfos
174
}
175
176
// calculateEntityCoverage calculates coverage for a single entity
177
func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape) EntityCoverageInfo {
178
	entityCoverageInfo := newEntityCoverageInfo(ref.EntityName)
179
180
	// Calculate relationships coverage
181
	entityCoverageInfo.UncoveredRelationships = findUncoveredRelationships(
182
		ref.EntityName,
183
		ref.Relationships,
184
		shape.Relationships,
185
	)
186
	entityCoverageInfo.CoverageRelationshipsPercent = calculateCoveragePercent(
187
		ref.Relationships,
188
		entityCoverageInfo.UncoveredRelationships,
189
	)
190
191
	// Calculate attributes coverage
192
	entityCoverageInfo.UncoveredAttributes = findUncoveredAttributes(
193
		ref.EntityName,
194
		ref.Attributes,
195
		shape.Attributes,
196
	)
197
	entityCoverageInfo.CoverageAttributesPercent = calculateCoveragePercent(
198
		ref.Attributes,
199
		entityCoverageInfo.UncoveredAttributes,
200
	)
201
202
	// Calculate assertions coverage for each scenario
203
	for _, scenario := range shape.Scenarios {
204
		uncovered := findUncoveredAssertions(
205
			ref.EntityName,
206
			ref.Assertions,
207
			scenario.Checks,
208
			scenario.EntityFilters,
209
		)
210
		// Only add to UncoveredAssertions if there are uncovered assertions
211
		if len(uncovered) > 0 {
212
			entityCoverageInfo.UncoveredAssertions[scenario.Name] = uncovered
213
		}
214
		entityCoverageInfo.CoverageAssertionsPercent[scenario.Name] = calculateCoveragePercent(
215
			ref.Assertions,
216
			uncovered,
217
		)
218
	}
219
220
	return entityCoverageInfo
221
}
222
223
// newEntityCoverageInfo creates a new EntityCoverageInfo with initialized fields
224
func newEntityCoverageInfo(entityName string) EntityCoverageInfo {
225
	return EntityCoverageInfo{
226
		EntityName:                   entityName,
227
		UncoveredRelationships:       []string{},
228
		UncoveredAttributes:          []string{},
229
		CoverageAssertionsPercent:    make(map[string]int),
230
		UncoveredAssertions:          make(map[string][]string),
231
		CoverageRelationshipsPercent: 0,
232
		CoverageAttributesPercent:    0,
233
	}
234
}
235
236
// findUncoveredRelationships finds relationships that are not covered in the shape
237
func findUncoveredRelationships(entityName string, expected, actual []string) []string {
238
	covered := extractCoveredRelationships(entityName, actual)
239
	uncovered := []string{}
240
241
	for _, relationship := range expected {
242
		if !slices.Contains(covered, relationship) {
243
			uncovered = append(uncovered, relationship)
244
		}
245
	}
246
247
	return uncovered
248
}
249
250
// findUncoveredAttributes finds attributes that are not covered in the shape
251
func findUncoveredAttributes(entityName string, expected, actual []string) []string {
252
	covered := extractCoveredAttributes(entityName, actual)
253
	uncovered := []string{}
254
255
	for _, attr := range expected {
256
		if !slices.Contains(covered, attr) {
257
			uncovered = append(uncovered, attr)
258
		}
259
	}
260
261
	return uncovered
262
}
263
264
// findUncoveredAssertions finds assertions that are not covered in the shape
265
func findUncoveredAssertions(entityName string, expected []string, checks []file.Check, filters []file.EntityFilter) []string {
266
	covered := extractCoveredAssertions(entityName, checks, filters)
267
	uncovered := []string{}
268
269
	for _, assertion := range expected {
270
		if !slices.Contains(covered, assertion) {
271
			uncovered = append(uncovered, assertion)
272
		}
273
	}
274
275
	return uncovered
276
}
277
278
// buildSchemaCoverageInfo builds the final SchemaCoverageInfo with total coverage
279
func buildSchemaCoverageInfo(entityCoverageInfos []EntityCoverageInfo) SchemaCoverageInfo {
280
	relationshipsCoverage, attributesCoverage, assertionsCoverage := calculateTotalCoverage(entityCoverageInfos)
281
282
	return SchemaCoverageInfo{
283
		EntityCoverageInfo:         entityCoverageInfos,
284
		TotalRelationshipsCoverage: relationshipsCoverage,
285
		TotalAttributesCoverage:    attributesCoverage,
286
		TotalAssertionsCoverage:    assertionsCoverage,
287
	}
288
}
289
290
// calculateCoveragePercent calculates coverage percentage based on total and uncovered elements
291
func calculateCoveragePercent(totalElements, uncoveredElements []string) int {
292
	totalCount := len(totalElements)
293
	if totalCount == 0 {
294
		return 100
295
	}
296
297
	coveredCount := totalCount - len(uncoveredElements)
298
	return (coveredCount * 100) / totalCount
299
}
300
301
// calculateTotalCoverage calculates average coverage percentages across all entities
302
func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int) {
303
	var (
304
		totalRelationships        int
305
		totalCoveredRelationships int
306
		totalAttributes           int
307
		totalCoveredAttributes    int
308
		totalAssertions           int
309
		totalCoveredAssertions    int
310
	)
311
312
	for _, entity := range entities {
313
		totalRelationships++
314
		totalCoveredRelationships += entity.CoverageRelationshipsPercent
315
316
		totalAttributes++
317
		totalCoveredAttributes += entity.CoverageAttributesPercent
318
319
		for _, assertionPercent := range entity.CoverageAssertionsPercent {
320
			totalAssertions++
321
			totalCoveredAssertions += assertionPercent
322
		}
323
	}
324
325
	return calculateAverageCoverage(totalRelationships, totalCoveredRelationships),
326
		calculateAverageCoverage(totalAttributes, totalCoveredAttributes),
327
		calculateAverageCoverage(totalAssertions, totalCoveredAssertions)
328
}
329
330
// calculateAverageCoverage calculates average coverage with zero-division guard
331
func calculateAverageCoverage(total, covered int) int {
332
	if total == 0 {
333
		return 100
334
	}
335
	return covered / total
336
}
337
338
// extractCoveredRelationships extracts covered relationships for a given entity from the shape
339
func extractCoveredRelationships(entityName string, relationships []string) []string {
340
	covered := []string{}
341
342
	for _, relationship := range relationships {
343
		tup, err := tuple.Tuple(relationship)
344
		if err != nil {
345
			continue
346
		}
347
348
		if tup.GetEntity().GetType() != entityName {
349
			continue
350
		}
351
352
		formatted := formatRelationship(
353
			tup.GetEntity().GetType(),
354
			tup.GetRelation(),
355
			tup.GetSubject().GetType(),
356
			tup.GetSubject().GetRelation(),
357
		)
358
		covered = append(covered, formatted)
359
	}
360
361
	return covered
362
}
363
364
// extractCoveredAttributes extracts covered attributes for a given entity from the shape
365
func extractCoveredAttributes(entityName string, attributes []string) []string {
366
	covered := []string{}
367
368
	for _, attrStr := range attributes {
369
		a, err := attribute.Attribute(attrStr)
370
		if err != nil {
371
			continue
372
		}
373
374
		if a.GetEntity().GetType() != entityName {
375
			continue
376
		}
377
378
		formatted := formatAttribute(a.GetEntity().GetType(), a.GetAttribute())
379
		covered = append(covered, formatted)
380
	}
381
382
	return covered
383
}
384
385
// extractCoveredAssertions extracts covered assertions for a given entity from checks and filters
386
func extractCoveredAssertions(entityName string, checks []file.Check, filters []file.EntityFilter) []string {
387
	covered := []string{}
388
389
	// Extract from checks
390
	for _, check := range checks {
391
		entity, err := tuple.E(check.Entity)
392
		if err != nil {
393
			continue
394
		}
395
396
		if entity.GetType() != entityName {
397
			continue
398
		}
399
400
		for permission := range check.Assertions {
401
			formatted := formatAssertion(entity.GetType(), permission)
402
			covered = append(covered, formatted)
403
		}
404
	}
405
406
	// Extract from entity filters
407
	for _, filter := range filters {
408
		if filter.EntityType != entityName {
409
			continue
410
		}
411
412
		for permission := range filter.Assertions {
413
			formatted := formatAssertion(filter.EntityType, permission)
414
			covered = append(covered, formatted)
415
		}
416
	}
417
418
	return covered
419
}
420
421
// formatRelationship formats a relationship string
422
func formatRelationship(entityName, relationName, subjectType, subjectRelation string) string {
423
	if subjectRelation != "" {
424
		return fmt.Sprintf("%s#%s@%s#%s", entityName, relationName, subjectType, subjectRelation)
425
	}
426
	return fmt.Sprintf("%s#%s@%s", entityName, relationName, subjectType)
427
}
428
429
// formatAttribute formats an attribute string
430
func formatAttribute(entityName, attributeName string) string {
431
	return fmt.Sprintf("%s#%s", entityName, attributeName)
432
}
433
434
// formatAssertion formats an assertion/permission string
435
func formatAssertion(entityName, permissionName string) string {
436
	return fmt.Sprintf("%s#%s", entityName, permissionName)
437
}
438