Passed
Pull Request — master (#2556)
by Tolga
03:23 queued 11s
created

engines.*EntityFilter.lt   C

Complexity

Conditions 8

Size

Total Lines 59
Code Lines 45

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 45
nop 6
dl 0
loc 59
rs 6.9333
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
package engines
2
3
import (
4
	"context"
5
	"errors"
6
7
	"golang.org/x/sync/errgroup"
8
9
	"github.com/Permify/permify/internal/schema"
10
	"github.com/Permify/permify/internal/storage"
11
	storageContext "github.com/Permify/permify/internal/storage/context"
12
	"github.com/Permify/permify/pkg/database"
13
	base "github.com/Permify/permify/pkg/pb/base/v1"
14
)
15
16
// EntityFilter is a struct that performs permission checks on a set of entities
17
type EntityFilter struct {
18
	// dataReader is responsible for reading relationship information
19
	dataReader storage.DataReader
20
21
	graph *schema.LinkedSchemaGraph
22
}
23
24
// NewEntityFilter creates a new EntityFilter engine
25
func NewEntityFilter(dataReader storage.DataReader, sch *base.SchemaDefinition) *EntityFilter {
26
	return &EntityFilter{
27
		dataReader: dataReader,
28
		graph:      schema.NewLinkedGraph(sch),
29
	}
30
}
31
32
// EntityFilter is a method of the EntityFilterEngine struct. It executes a permission request for linked entities.
33
func (engine *EntityFilter) EntityFilter(
34
	ctx context.Context, // A context used for tracing and cancellation.
35
	request *base.PermissionEntityFilterRequest, // A permission request for linked entities.
36
	visits *VisitsMap, // A map that keeps track of visited entities to avoid infinite loops.
37
	publisher *BulkEntityPublisher, // A custom publisher that publishes results in bulk.
38
) (err error) { // Returns an error if one occurs during execution.
39
	// Check if direct result
40
	if request.GetEntrance().GetType() == request.GetSubject().GetType() && request.GetEntrance().GetValue() == request.GetSubject().GetRelation() {
41
		found := &base.Entity{
42
			Type: request.GetSubject().GetType(),
43
			Id:   request.GetSubject().GetId(),
44
		}
45
46
		if !visits.AddPublished(found) { // If the entity and relation has already been visited.
47
			return nil
48
		}
49
50
		// If the entity reference is the same as the subject, publish the result directly and return.
51
		publisher.Publish(found, &base.PermissionCheckRequestMetadata{
52
			SnapToken:     request.GetMetadata().GetSnapToken(),
53
			SchemaVersion: request.GetMetadata().GetSchemaVersion(),
54
			Depth:         request.GetMetadata().GetDepth(),
55
		}, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED)
56
	}
57
58
	// Retrieve linked entrances
59
	var entrances []*schema.LinkedEntrance
60
	entrances, err = engine.graph.LinkedEntrances(
61
		request.GetEntrance(),
62
		&base.Entrance{
63
			Type:  request.GetSubject().GetType(),
64
			Value: request.GetSubject().GetRelation(),
65
		},
66
	) // Retrieve the linked entrances between the entity reference and subject.
67
68
	if entrances == nil {
69
		return nil
70
	}
71
72
	// Create a new context for executing goroutines and a cancel function.
73
	cctx, cancel := context.WithCancel(ctx)
74
	defer cancel()
75
76
	// Create a new errgroup and a new context that inherits the original context.
77
	g, cont := errgroup.WithContext(cctx)
78
79
	// Loop over each linked entrance.
80
	for _, entrance := range entrances {
81
		// Switch on the kind of linked entrance.
82
		switch entrance.LinkedEntranceKind() {
83
		case schema.RelationLinkedEntrance: // If the linked entrance is a relation entrance.
84
			err = engine.relationEntrance(cont, request, entrance, visits, g, publisher) // Call the relation entrance method.
85
			if err != nil {
86
				return err
87
			}
88
		case schema.ComputedUserSetLinkedEntrance: // If the linked entrance is a computed user set entrance.
89
			err = engine.lt(cont, request, &base.EntityAndRelation{ // Call the run method with a new entity and relation.
90
				Entity: &base.Entity{
91
					Type: entrance.TargetEntrance.GetType(),
92
					Id:   request.GetSubject().GetId(),
93
				},
94
				Relation: entrance.TargetEntrance.GetValue(),
95
			}, visits, g, publisher)
96
			if err != nil {
97
				return err
98
			}
99
		case schema.AttributeLinkedEntrance: // If the linked entrance is a computed user set entrance.
100
			err = engine.attributeEntrance(cont, request, entrance, visits, publisher) // Call the tuple to user set entrance method.
101
			if err != nil {
102
				return err
103
			}
104
		case schema.TupleToUserSetLinkedEntrance: // If the linked entrance is a tuple to user set entrance.
105
			err = engine.tupleToUserSetEntrance(cont, request, entrance, visits, g, publisher) // Call the tuple to user set entrance method.
106
			if err != nil {
107
				return err
108
			}
109
		case schema.PathChainLinkedEntrance: // If the linked entrance is a path chain entrance.
110
			err = engine.pathChainEntrance(cont, request, entrance, visits, publisher) // Call the path chain entrance method.
111
			if err != nil {
112
				return err
113
			}
114
		default:
115
			return errors.New("unknown linked entrance type") // Return an error if the linked entrance is of an unknown type.
116
		}
117
	}
118
119
	return g.Wait() // Wait for all goroutines in the errgroup to complete and return any errors that occur.
120
}
121
122
// relationEntrance is a method of the EntityFilterEngine struct. It handles relation entrances.
123
func (engine *EntityFilter) attributeEntrance(
124
	ctx context.Context, // A context used for tracing and cancellation.
125
	request *base.PermissionEntityFilterRequest, // A permission request for linked entities.
126
	entrance *schema.LinkedEntrance, // A linked entrance.
127
	visits *VisitsMap, // A map that keeps track of visited entities to avoid infinite loops.
128
	publisher *BulkEntityPublisher, // A custom publisher that publishes results in bulk.
129
) error { // Returns an error if one occurs during execution.
130
	// attributeEntrance only handles direct attribute access
131
	if !visits.AddEA(entrance.TargetEntrance.GetType(), entrance.TargetEntrance.GetValue()) {
132
		return nil
133
	}
134
135
	// Retrieve the scope associated with the target entrance type
136
	scope, exists := request.GetScope()[entrance.TargetEntrance.GetType()]
137
	var data []string
138
	if exists {
139
		data = scope.GetData()
140
	}
141
142
	// Query attributes directly
143
	filter := &base.AttributeFilter{
144
		Entity: &base.EntityFilter{
145
			Type: entrance.TargetEntrance.GetType(),
146
			Ids:  data,
147
		},
148
		Attributes: []string{entrance.TargetEntrance.GetValue()},
149
	}
150
151
	pagination := database.NewCursorPagination(database.Cursor(request.GetCursor()), database.Sort("entity_id"))
152
153
	cti, err := storageContext.NewContextualAttributes(request.GetContext().GetAttributes()...).QueryAttributes(filter, pagination)
154
	if err != nil {
155
		return err
156
	}
157
158
	rit, err := engine.dataReader.QueryAttributes(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), pagination)
159
	if err != nil {
160
		return err
161
	}
162
163
	it := database.NewUniqueAttributeIterator(rit, cti)
164
165
	// Publish entities directly for regular case
166
	for it.HasNext() {
167
		current, ok := it.GetNext()
168
		if !ok {
169
			break
170
		}
171
172
		entity := &base.Entity{
173
			Type: entrance.TargetEntrance.GetType(),
174
			Id:   current.GetEntity().GetId(),
175
		}
176
177
		if !visits.AddPublished(entity) {
178
			continue
179
		}
180
181
		publisher.Publish(entity, &base.PermissionCheckRequestMetadata{
182
			SnapToken:     request.GetMetadata().GetSnapToken(),
183
			SchemaVersion: request.GetMetadata().GetSchemaVersion(),
184
			Depth:         request.GetMetadata().GetDepth(),
185
		}, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED)
186
	}
187
188
	return nil
189
}
190
191
// relationEntrance is a method of the EntityFilterEngine struct. It handles relation entrances.
192
func (engine *EntityFilter) relationEntrance(
193
	ctx context.Context, // A context used for tracing and cancellation.
194
	request *base.PermissionEntityFilterRequest, // A permission request for linked entities.
195
	entrance *schema.LinkedEntrance, // A linked entrance.
196
	visits *VisitsMap, // A map that keeps track of visited entities to avoid infinite loops.
197
	g *errgroup.Group, // An errgroup used for executing goroutines.
198
	publisher *BulkEntityPublisher, // A custom publisher that publishes results in bulk.
199
) error { // Returns an error if one occurs during execution.
200
	// Retrieve the scope associated with the target entrance type.
201
	// Check if it exists to avoid accessing a nil map entry.
202
	scope, exists := request.GetScope()[entrance.TargetEntrance.GetType()]
203
204
	// Initialize data as an empty slice of strings.
205
	var data []string
206
207
	// If the scope exists, assign its Data field to the data slice.
208
	if exists {
209
		data = scope.GetData()
210
	}
211
212
	// Define a TupleFilter. This specifies which tuples we're interested in.
213
	// We want tuples that match the entity type and ID from the request, and have a specific relation.
214
	filter := &base.TupleFilter{
215
		Entity: &base.EntityFilter{
216
			Type: entrance.TargetEntrance.GetType(),
217
			Ids:  data,
218
		},
219
		Relation: entrance.TargetEntrance.GetValue(),
220
		Subject: &base.SubjectFilter{
221
			Type:     request.GetSubject().GetType(),
222
			Ids:      []string{request.GetSubject().GetId()},
223
			Relation: request.GetSubject().GetRelation(),
224
		},
225
	}
226
227
	var (
228
		cti, rit   *database.TupleIterator
229
		err        error
230
		pagination database.CursorPagination
231
	)
232
233
	// Determine the pagination settings based on the entity type in the request.
234
	// If the entity type matches the target entrance, use cursor pagination with sorting by "entity_id".
235
	// Otherwise, use the default pagination settings.
236
	if request.GetEntrance().GetType() == entrance.TargetEntrance.GetType() {
237
		pagination = database.NewCursorPagination(database.Cursor(request.GetCursor()), database.Sort("entity_id"))
238
	} else {
239
		pagination = database.NewCursorPagination()
240
	}
241
242
	// Query the relationships using the specified pagination settings.
243
	// The context tuples are filtered based on the provided filter.
244
	cti, err = storageContext.NewContextualTuples(request.GetContext().GetTuples()...).QueryRelationships(filter, pagination)
245
	if err != nil {
246
		return err
247
	}
248
249
	// Query the relationships for the entity in the request.
250
	// The results are filtered based on the provided filter and pagination settings.
251
	rit, err = engine.dataReader.QueryRelationships(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), pagination)
252
	if err != nil {
253
		return err
254
	}
255
256
	// Create a new UniqueTupleIterator from the two TupleIterators.
257
	// NewUniqueTupleIterator() ensures that the iterator only returns unique tuples.
258
	it := database.NewUniqueTupleIterator(rit, cti)
259
260
	for it.HasNext() { // Loop over each relationship.
261
		// Get the next tuple's subject.
262
		current, ok := it.GetNext()
263
		if !ok {
264
			break
265
		}
266
		g.Go(func() error {
267
			return engine.lt(ctx, request, &base.EntityAndRelation{ // Call the run method with a new entity and relation.
268
				Entity: &base.Entity{
269
					Type: current.GetEntity().GetType(),
270
					Id:   current.GetEntity().GetId(),
271
				},
272
				Relation: current.GetRelation(),
273
			}, visits, g, publisher)
274
		})
275
	}
276
	return nil
277
}
278
279
// tupleToUserSetEntrance is a method of the EntityFilterEngine struct. It handles tuple to user set entrances.
280
func (engine *EntityFilter) tupleToUserSetEntrance(
281
	// A context used for tracing and cancellation.
282
	ctx context.Context,
283
	// A permission request for linked entities.
284
	request *base.PermissionEntityFilterRequest,
285
	// A linked entrance.
286
	entrance *schema.LinkedEntrance,
287
	// A map that keeps track of visited entities to avoid infinite loops.
288
	visits *VisitsMap,
289
	// An errgroup used for executing goroutines.
290
	g *errgroup.Group,
291
	// A custom publisher that publishes results in bulk.
292
	publisher *BulkEntityPublisher,
293
) error { // Returns an error if one occurs during execution.
294
	// Retrieve the scope associated with the target entrance type.
295
	// Check if it exists to avoid accessing a nil map entry.
296
	scope, exists := request.GetScope()[entrance.TargetEntrance.GetType()]
297
298
	// Initialize data as an empty slice of strings.
299
	var data []string
300
301
	// If the scope exists, assign its Data field to the data slice.
302
	if exists {
303
		data = scope.GetData()
304
	}
305
306
	// Define a TupleFilter. This specifies which tuples we're interested in.
307
	// We want tuples that match the entity type and ID from the request, and have a specific relation.
308
	filter := &base.TupleFilter{
309
		Entity: &base.EntityFilter{
310
			Type: entrance.TargetEntrance.GetType(),
311
			Ids:  data,
312
		},
313
		Relation: entrance.TupleSetRelation, // Query for relationships that match the tuple set relation.
314
		Subject: &base.SubjectFilter{
315
			Type:     request.GetSubject().GetType(),
316
			Ids:      []string{request.GetSubject().GetId()},
317
			Relation: "",
318
		},
319
	}
320
321
	var (
322
		cti, rit   *database.TupleIterator
323
		err        error
324
		pagination database.CursorPagination
325
	)
326
327
	// Determine the pagination settings based on the entity type in the request.
328
	// If the entity type matches the target entrance, use cursor pagination with sorting by "entity_id".
329
	// Otherwise, use the default pagination settings.
330
	if request.GetEntrance().GetType() == entrance.TargetEntrance.GetType() {
331
		pagination = database.NewCursorPagination(database.Cursor(request.GetCursor()), database.Sort("entity_id"))
332
	} else {
333
		pagination = database.NewCursorPagination()
334
	}
335
336
	// Query the relationships using the specified pagination settings.
337
	// The context tuples are filtered based on the provided filter.
338
	cti, err = storageContext.NewContextualTuples(request.GetContext().GetTuples()...).QueryRelationships(filter, pagination)
339
	if err != nil {
340
		return err
341
	}
342
343
	// Query the relationships for the entity in the request.
344
	// The results are filtered based on the provided filter and pagination settings.
345
	rit, err = engine.dataReader.QueryRelationships(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), pagination)
346
	if err != nil {
347
		return err
348
	}
349
350
	// Create a new UniqueTupleIterator from the two TupleIterators.
351
	// NewUniqueTupleIterator() ensures that the iterator only returns unique tuples.
352
	it := database.NewUniqueTupleIterator(rit, cti)
353
354
	for it.HasNext() { // Loop over each relationship.
355
		// Get the next tuple's subject.
356
		current, ok := it.GetNext()
357
		if !ok {
358
			break
359
		}
360
		g.Go(func() error {
361
			return engine.lt(ctx, request, &base.EntityAndRelation{ // Call the run method with a new entity and relation.
362
				Entity: &base.Entity{
363
					Type: entrance.TargetEntrance.GetType(),
364
					Id:   current.GetEntity().GetId(),
365
				},
366
				Relation: entrance.TargetEntrance.GetValue(),
367
			}, visits, g, publisher)
368
		})
369
	}
370
	return nil
371
}
372
373
// run is a method of the EntityFilterEngine struct. It executes the linked entity engine for a given request.
374
func (engine *EntityFilter) lt(
375
	ctx context.Context, // A context used for tracing and cancellation.
376
	request *base.PermissionEntityFilterRequest, // A permission request for linked entities.
377
	found *base.EntityAndRelation, // An entity and relation that was previously found.
378
	visits *VisitsMap, // A map that keeps track of visited entities to avoid infinite loops.
379
	g *errgroup.Group, // An errgroup used for executing goroutines.
380
	publisher *BulkEntityPublisher, // A custom publisher that publishes results in bulk.
381
) error { // Returns an error if one occurs during execution.
382
	if !visits.AddER(found.GetEntity(), found.GetRelation()) { // If the entity and relation has already been visited.
383
		return nil
384
	}
385
386
	var err error
387
388
	// Retrieve linked entrances
389
	var entrances []*schema.LinkedEntrance
390
	entrances, err = engine.graph.LinkedEntrances(
391
		request.GetEntrance(),
392
		&base.Entrance{
393
			Type:  request.GetSubject().GetType(),
394
			Value: request.GetSubject().GetRelation(),
395
		},
396
	) // Retrieve the linked entrances for the request.
397
	if err != nil {
398
		return err
399
	}
400
401
	if entrances == nil { // If there are no linked entrances for the request.
402
		if found.GetEntity().GetType() == request.GetEntrance().GetType() && found.GetRelation() == request.GetEntrance().GetValue() { // Check if the found entity matches the requested entity reference.
403
			if !visits.AddPublished(found.GetEntity()) { // If the entity and relation has already been visited.
404
				return nil
405
			}
406
			publisher.Publish(found.GetEntity(), &base.PermissionCheckRequestMetadata{ // Publish the found entity with the permission check metadata.
407
				SnapToken:     request.GetMetadata().GetSnapToken(),
408
				SchemaVersion: request.GetMetadata().GetSchemaVersion(),
409
				Depth:         request.GetMetadata().GetDepth(),
410
			}, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED)
411
			return nil
412
		}
413
		return nil // Otherwise, return without publishing any results.
414
	}
415
416
	g.Go(func() error {
417
		return engine.EntityFilter(ctx, &base.PermissionEntityFilterRequest{ // Call the Run method recursively with a new permission request.
418
			TenantId: request.GetTenantId(),
419
			Entrance: request.GetEntrance(),
420
			Subject: &base.Subject{
421
				Type:     found.GetEntity().GetType(),
422
				Id:       found.GetEntity().GetId(),
423
				Relation: found.GetRelation(),
424
			},
425
			Scope:    request.GetScope(),
426
			Metadata: request.GetMetadata(),
427
			Context:  request.GetContext(),
428
			Cursor:   request.GetCursor(),
429
		}, visits, publisher)
430
	})
431
	return nil
432
}
433
434
// pathChainEntrance handles multi-hop relation chain traversal for nested attributes
435
//
436
// TODO: This function can be optimized for better performance by implementing smart batching logic:
437
// - Extract unique attributes from path chain entrances to avoid duplicate queries
438
// - Implement batch vs individual processing based on scope and attribute count:
439
//   - Use batch mode when we have scope (limited entity IDs) or few attributes (<=1)
440
//   - Use individual mode when no scope and multiple attributes to avoid loading large result sets
441
//   - Refactor into smaller helper functions: extractUniqueAttributes, getScopeIds, shouldUseBatchMode,
442
//     processBatchMode, processIndividualMode, queryAttributesBatch, processEntranceWithResults
443
//   - Remove debug statements after optimization is tested
444
func (engine *EntityFilter) pathChainEntrance(
445
	ctx context.Context,
446
	request *base.PermissionEntityFilterRequest,
447
	entrance *schema.LinkedEntrance,
448
	visits *VisitsMap,
449
	publisher *BulkEntityPublisher,
450
) error {
451
	if !visits.AddEA(entrance.TargetEntrance.GetType(), entrance.TargetEntrance.GetValue()) {
452
		return nil
453
	}
454
455
	// 1. Query attributes of the target type with scope optimization
456
	scope, exists := request.GetScope()[entrance.TargetEntrance.GetType()]
457
	var data []string
458
	if exists {
459
		data = scope.GetData()
460
	}
461
462
	filter := &base.AttributeFilter{
463
		Entity: &base.EntityFilter{
464
			Type: entrance.TargetEntrance.GetType(),
465
			Ids:  data,
466
		},
467
		Attributes: []string{entrance.TargetEntrance.GetValue()},
468
	}
469
470
	pagination := database.NewCursorPagination()
471
	cti, err := storageContext.NewContextualAttributes(request.GetContext().GetAttributes()...).QueryAttributes(filter, pagination)
472
	if err != nil {
473
		return err
474
	}
475
476
	rit, err := engine.dataReader.QueryAttributes(ctx, request.GetTenantId(), filter, request.GetMetadata().GetSnapToken(), pagination)
477
	if err != nil {
478
		return err
479
	}
480
481
	it := database.NewUniqueAttributeIterator(rit, cti)
482
483
	// 2. Collect all attribute entity IDs first (batch approach)
484
	var attributeEntityIds []string
485
	sourceType := request.GetEntrance().GetType()
486
	targetType := entrance.TargetEntrance.GetType()
487
488
	// Collect all entity IDs that have the attribute
489
	for it.HasNext() {
490
		current, ok := it.GetNext()
491
		if !ok {
492
			break
493
		}
494
		attributeEntityIds = append(attributeEntityIds, current.GetEntity().GetId())
495
	}
496
497
	if len(attributeEntityIds) == 0 {
498
		return nil
499
	}
500
501
	// 3. Use the PathChain from entrance to traverse relation chain
502
	chain := entrance.PathChain
503
	if len(chain) == 0 {
504
		return errors.New("PathChainLinkedEntrance missing PathChain")
505
	}
506
507
	// 4. Fold IDs across the relation chain from attribute type back to source type
508
	currentType := targetType
509
	currentIds := attributeEntityIds
510
511
	for i := len(chain) - 1; i >= 0; i-- {
512
		hop := chain[i] // hop.Type == left entity type; hop.Relation relates hop.Type -> currentType
513
514
		// Apply scope optimization only on the final hop (source type)
515
		var entIds []string
516
		if i == 0 {
517
			if s, exists := request.GetScope()[sourceType]; exists {
518
				entIds = s.GetData()
519
			}
520
		}
521
522
		relationFilter := &base.TupleFilter{
523
			Entity: &base.EntityFilter{
524
				Type: hop.GetType(),
525
				Ids:  entIds,
526
			},
527
			Relation: hop.GetRelation(),
528
			Subject: &base.SubjectFilter{
529
				Type:     currentType,
530
				Ids:      currentIds,
531
				Relation: "",
532
			},
533
		}
534
535
		pagination := database.NewCursorPagination()
536
		ctiR, err := storageContext.NewContextualTuples(request.GetContext().GetTuples()...).QueryRelationships(relationFilter, pagination)
537
		if err != nil {
538
			return err
539
		}
540
541
		ritR, err := engine.dataReader.QueryRelationships(ctx, request.GetTenantId(), relationFilter, request.GetMetadata().GetSnapToken(), pagination)
542
		if err != nil {
543
			return err
544
		}
545
546
		relationIt := database.NewUniqueTupleIterator(ritR, ctiR)
547
548
		// collect next frontier (left entity IDs)
549
		nextIdsSet := make(map[string]struct{})
550
		for relationIt.HasNext() {
551
			tuple, ok := relationIt.GetNext()
552
			if !ok {
553
				break
554
			}
555
			nextIdsSet[tuple.GetEntity().GetId()] = struct{}{}
556
		}
557
558
		var nextIds []string
559
		for id := range nextIdsSet {
560
			nextIds = append(nextIds, id)
561
		}
562
563
		if len(nextIdsSet) == 0 {
564
			return nil // No path found through this hop
565
		}
566
567
		// prepare for next hop
568
		currentType = hop.GetType()
569
		currentIds = nextIds
570
	}
571
572
	// 5. Publish all resolved source entities
573
	for _, id := range currentIds {
574
		entity := &base.Entity{Type: sourceType, Id: id}
575
		if !visits.AddPublished(entity) {
576
			continue
577
		}
578
579
		publisher.Publish(entity, &base.PermissionCheckRequestMetadata{
580
			SnapToken:     request.GetMetadata().GetSnapToken(),
581
			SchemaVersion: request.GetMetadata().GetSchemaVersion(),
582
			Depth:         request.GetMetadata().GetDepth(),
583
		}, request.GetContext(), base.CheckResult_CHECK_RESULT_UNSPECIFIED)
584
	}
585
586
	return nil
587
}
588