2020-04-30 17:23:44 +02:00
|
|
|
using System;
|
|
|
|
using System.Collections.Generic;
|
|
|
|
using System.Linq;
|
2020-05-02 13:09:49 +02:00
|
|
|
using AspectedRouting.Language.Expression;
|
|
|
|
using AspectedRouting.Language.Functions;
|
|
|
|
using AspectedRouting.Language.Typ;
|
|
|
|
using Type = AspectedRouting.Language.Typ.Type;
|
2020-04-30 17:23:44 +02:00
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
namespace AspectedRouting.Language
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
|
|
|
public static class Analysis
|
|
|
|
{
|
|
|
|
public static string GenerateFullOutputCsv(Context c, IExpression e)
|
|
|
|
{
|
|
|
|
var possibleTags = e.PossibleTags();
|
|
|
|
|
|
|
|
var defaultValues = new List<string>
|
|
|
|
{
|
|
|
|
"0",
|
|
|
|
"30",
|
|
|
|
"50",
|
|
|
|
"yes",
|
|
|
|
"no",
|
|
|
|
"SomeName"
|
|
|
|
};
|
|
|
|
|
|
|
|
Console.WriteLine(e);
|
|
|
|
var keys = e.PossibleTags().Keys.ToList();
|
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
|
2020-04-30 17:23:44 +02:00
|
|
|
var results = possibleTags.OnAllCombinations(
|
|
|
|
tags =>
|
|
|
|
{
|
|
|
|
Console.WriteLine(tags.Pretty());
|
|
|
|
return (new Apply(e, new Constant(tags)).Evaluate(c), tags);
|
|
|
|
}, defaultValues).ToList();
|
|
|
|
|
|
|
|
var csv = "result, " + string.Join(", ", keys) + "\n";
|
|
|
|
|
|
|
|
foreach (var (result, tags) in results)
|
|
|
|
{
|
|
|
|
csv += result + ", " +
|
|
|
|
string.Join(", ",
|
|
|
|
keys.Select(key =>
|
|
|
|
{
|
|
|
|
if (tags.ContainsKey(key))
|
|
|
|
{
|
|
|
|
return tags[key];
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
csv += "\n";
|
|
|
|
}
|
|
|
|
|
|
|
|
return csv;
|
|
|
|
}
|
|
|
|
|
|
|
|
public static IEnumerable<T> OnAllCombinations<T>(this Dictionary<string, List<string>> possibleTags,
|
|
|
|
Func<Dictionary<string, string>, T> f, List<string> defaultValues)
|
|
|
|
{
|
|
|
|
var newDict = new Dictionary<string, List<string>>();
|
|
|
|
foreach (var (key, value) in possibleTags)
|
|
|
|
{
|
|
|
|
if (value.Count == 0)
|
|
|
|
{
|
|
|
|
// This value is a list of possible values, e.g. a double
|
|
|
|
// We replace them with various other
|
|
|
|
newDict[key] = defaultValues;
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
newDict[key] = value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
possibleTags = newDict;
|
|
|
|
|
|
|
|
var keys = possibleTags.Keys.ToList();
|
|
|
|
var currentKeyIndex = new int[possibleTags.Count];
|
|
|
|
for (int i = 0; i < currentKeyIndex.Length; i++)
|
|
|
|
{
|
|
|
|
currentKeyIndex[i] = -1;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool SelectNext()
|
|
|
|
{
|
|
|
|
var j = 0;
|
|
|
|
while (j < currentKeyIndex.Length)
|
|
|
|
{
|
|
|
|
currentKeyIndex[j]++;
|
|
|
|
if (currentKeyIndex[j] ==
|
|
|
|
possibleTags[keys[j]].Count)
|
|
|
|
{
|
|
|
|
// This index rolls over
|
|
|
|
currentKeyIndex[j] = -1;
|
|
|
|
j++;
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
do
|
|
|
|
{
|
|
|
|
var tags = new Dictionary<string, string>();
|
|
|
|
for (int i = 0; i < keys.Count(); i++)
|
|
|
|
{
|
|
|
|
var key = keys[i];
|
|
|
|
var j = currentKeyIndex[i];
|
|
|
|
if (j >= 0)
|
|
|
|
{
|
|
|
|
var value = possibleTags[key][j];
|
|
|
|
tags.Add(key, value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
yield return f(tags);
|
|
|
|
} while (SelectNext());
|
|
|
|
}
|
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
public static Dictionary<string, (List<Type> Types, string inFunction)> UsedParameters(
|
2020-04-30 17:23:44 +02:00
|
|
|
this ProfileMetaData profile, Context context)
|
|
|
|
{
|
2020-05-05 03:21:37 +02:00
|
|
|
var parameters = new Dictionary<string, (List<Type> Types, string usageLocation)>();
|
2020-04-30 17:23:44 +02:00
|
|
|
|
2020-05-04 17:41:48 +02:00
|
|
|
|
2020-04-30 17:23:44 +02:00
|
|
|
void AddParams(IExpression e, string inFunction)
|
|
|
|
{
|
|
|
|
var parms = e.UsedParameters();
|
|
|
|
foreach (var param in parms)
|
|
|
|
{
|
|
|
|
if (parameters.TryGetValue(param.ParamName, out var typesOldUsage))
|
|
|
|
{
|
|
|
|
var (types, oldUsage) = typesOldUsage;
|
|
|
|
var unified = types.SpecializeTo(param.Types);
|
|
|
|
if (unified == null)
|
|
|
|
{
|
|
|
|
throw new ArgumentException("Inconsistent parameter usage: the paremeter " +
|
|
|
|
param.ParamName + " is used\n" +
|
2020-05-05 03:21:37 +02:00
|
|
|
$" in {oldUsage} as {string.Join(",", types)}\n" +
|
|
|
|
$" in {inFunction} as {string.Join(",", param.Types)}\n" +
|
2020-04-30 17:23:44 +02:00
|
|
|
$"which can not be unified");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
2020-05-02 13:09:49 +02:00
|
|
|
parameters[param.ParamName] = (param.Types.ToList(), inFunction);
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-05-05 03:21:37 +02:00
|
|
|
AddParams(profile.Access, "profile definition for " + profile.Name + ".access");
|
|
|
|
AddParams(profile.Oneway, "profile definition for " + profile.Name + ".oneway");
|
|
|
|
AddParams(profile.Speed, "profile definition for " + profile.Name + ".speed");
|
2020-04-30 17:23:44 +02:00
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
foreach (var (key, expr) in profile.Priority)
|
|
|
|
{
|
|
|
|
AddParams(new Parameter(key), profile.Name + ".priority.lefthand");
|
|
|
|
AddParams(expr, profile.Name + ".priority");
|
|
|
|
}
|
|
|
|
|
2020-05-04 17:41:48 +02:00
|
|
|
var calledFunctions = profile.CalledFunctionsRecursive(context).Values
|
|
|
|
.SelectMany(ls => ls).ToHashSet();
|
|
|
|
foreach (var calledFunction in calledFunctions)
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
2020-05-04 17:41:48 +02:00
|
|
|
var func = context.GetFunction(calledFunction);
|
2020-05-05 03:21:37 +02:00
|
|
|
if (func is AspectMetadata meta && meta.ProfileInternal)
|
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
AddParams(func, "function " + calledFunction);
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
|
2020-05-04 17:41:48 +02:00
|
|
|
|
2020-04-30 17:23:44 +02:00
|
|
|
return parameters;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static HashSet<Parameter> UsedParameters(this IExpression e)
|
|
|
|
{
|
|
|
|
var result = new HashSet<Parameter>();
|
|
|
|
e.Visit(expr =>
|
|
|
|
{
|
|
|
|
if (expr is Parameter p)
|
|
|
|
{
|
|
|
|
result.Add(p);
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2020-05-04 17:41:48 +02:00
|
|
|
|
|
|
|
public static Dictionary<string, List<string>> CalledFunctionsRecursive(this ProfileMetaData profile,
|
|
|
|
Context c)
|
|
|
|
{
|
|
|
|
// Read as: this function calls the value-function
|
|
|
|
var result = new Dictionary<string, List<string>>();
|
|
|
|
var calledFunctions = new Queue<string>();
|
|
|
|
|
|
|
|
void ScanExpression(IExpression e, string inFunction)
|
|
|
|
{
|
|
|
|
result.Add(inFunction, new List<string>());
|
|
|
|
|
|
|
|
e.Visit(x =>
|
|
|
|
{
|
|
|
|
if (x is FunctionCall fc)
|
|
|
|
{
|
|
|
|
result[inFunction].Add(fc.CalledFunctionName);
|
|
|
|
if (!result.ContainsKey(fc.CalledFunctionName))
|
|
|
|
{
|
|
|
|
calledFunctions.Enqueue(fc.CalledFunctionName);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ScanExpression(profile.Access, profile.Name + ".access");
|
|
|
|
ScanExpression(profile.Oneway, profile.Name + ".oneway");
|
|
|
|
ScanExpression(profile.Speed, profile.Name + ".speed");
|
|
|
|
|
|
|
|
foreach (var (key, expr) in profile.Priority)
|
|
|
|
{
|
|
|
|
ScanExpression(new Parameter(key), $"{profile.Name}.priority.{key}.lefthand");
|
|
|
|
ScanExpression(expr, $"{profile.Name}.priority.{key}");
|
|
|
|
}
|
|
|
|
|
|
|
|
while (calledFunctions.TryDequeue(out var calledFunction))
|
|
|
|
{
|
|
|
|
var func = c.GetFunction(calledFunction);
|
|
|
|
ScanExpression(func, calledFunction);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2020-04-30 17:23:44 +02:00
|
|
|
public static string TypeBreakdown(this IExpression e)
|
|
|
|
{
|
|
|
|
var text = "";
|
|
|
|
e.Visit(x =>
|
|
|
|
{
|
|
|
|
text += $"\n\n{x}\n : {string.Join("\n : ", x.Types)}";
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
return text;
|
|
|
|
}
|
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
public static void SanityCheckProfile(this ProfileMetaData pmd, Context context)
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
2020-05-05 03:21:37 +02:00
|
|
|
var defaultParameters = pmd.DefaultParameters.Keys.Select(k => k.TrimStart('#'));
|
2020-04-30 17:23:44 +02:00
|
|
|
|
2020-05-04 17:41:48 +02:00
|
|
|
|
|
|
|
var usedMetadata = pmd.UsedParameters(context);
|
|
|
|
|
|
|
|
string MetaList(IEnumerable<string> paramNames)
|
|
|
|
{
|
|
|
|
var metaInfo = "";
|
|
|
|
foreach (var paramName in paramNames)
|
|
|
|
{
|
|
|
|
var _ = usedMetadata.TryGetValue(paramName, out var inFunction) ||
|
|
|
|
usedMetadata.TryGetValue('#' + paramName, out inFunction);
|
|
|
|
metaInfo += $"\n - {paramName} (used in {inFunction.inFunction})";
|
|
|
|
}
|
|
|
|
|
|
|
|
return metaInfo;
|
|
|
|
}
|
|
|
|
|
2020-05-05 03:21:37 +02:00
|
|
|
var usedParameters = usedMetadata.Keys.Select(key => key.TrimStart('#')).ToList();
|
2020-04-30 17:23:44 +02:00
|
|
|
|
|
|
|
var diff = usedParameters.ToHashSet().Except(defaultParameters).ToList();
|
|
|
|
if (diff.Any())
|
|
|
|
{
|
2020-05-04 17:41:48 +02:00
|
|
|
throw new ArgumentException("No default value set for parameter: " + MetaList(diff));
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
var unused = defaultParameters.Except(usedParameters);
|
|
|
|
if (unused.Any())
|
|
|
|
{
|
|
|
|
throw new ArgumentException("A default value is set for parameter, but it is unused: " +
|
|
|
|
string.Join(", ", unused));
|
|
|
|
}
|
|
|
|
|
2020-05-04 17:41:48 +02:00
|
|
|
var paramsUsedInBehaviour = new HashSet<string>();
|
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
foreach (var (behaviourName, behaviourParams) in pmd.Behaviours)
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
|
|
|
var sum = 0.0;
|
2020-05-02 13:09:49 +02:00
|
|
|
var explanation = "";
|
2020-05-05 03:21:37 +02:00
|
|
|
paramsUsedInBehaviour.UnionWith(behaviourParams.Keys.Select(k => k.Trim('#')));
|
2020-05-02 13:09:49 +02:00
|
|
|
foreach (var (paramName, _) in pmd.Priority)
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
2020-05-02 13:09:49 +02:00
|
|
|
if (!pmd.DefaultParameters.ContainsKey(paramName))
|
|
|
|
{
|
|
|
|
throw new ArgumentException(
|
|
|
|
$"The behaviour {behaviourName} uses a parameter for which no default is set: {paramName}");
|
|
|
|
}
|
2020-05-04 17:41:48 +02:00
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
if (!behaviourParams.TryGetValue(paramName, out var weight))
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
2020-05-02 13:09:49 +02:00
|
|
|
explanation += $"\n - {paramName} = default (not set)";
|
2020-04-30 17:23:44 +02:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2020-05-02 13:09:49 +02:00
|
|
|
var weightObj = weight.Evaluate(context);
|
|
|
|
|
|
|
|
if (!(weightObj is double d))
|
2020-04-30 17:23:44 +02:00
|
|
|
{
|
2020-05-04 17:41:48 +02:00
|
|
|
throw new ArgumentException(
|
|
|
|
$"The parameter {paramName} is not a numeric value in profile {behaviourName}");
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
sum += Math.Abs(d);
|
2020-05-02 13:09:49 +02:00
|
|
|
explanation += $"\n - {paramName} = {d}";
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (Math.Abs(sum) < 0.0001)
|
|
|
|
{
|
2020-05-02 13:09:49 +02:00
|
|
|
throw new ArgumentException("Profile " + behaviourName +
|
|
|
|
": the summed parameters to calculate the weight are zero or very low:" +
|
|
|
|
explanation);
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
}
|
2020-05-04 17:41:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
var defaultOnly = defaultParameters.Except(paramsUsedInBehaviour).ToList();
|
|
|
|
if (defaultOnly.Any())
|
|
|
|
{
|
|
|
|
Console.WriteLine(
|
|
|
|
$"[{pmd.Name}] WARNING: Some parameters only have a default value: {string.Join(", ", defaultOnly)}");
|
|
|
|
}
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public static void SanityCheck(this IExpression e)
|
|
|
|
{
|
|
|
|
e.Visit(expr =>
|
|
|
|
{
|
|
|
|
var order = new List<IExpression>();
|
|
|
|
var mapping = new List<IExpression>();
|
2020-05-02 13:09:49 +02:00
|
|
|
if (Deconstruct.UnApply(
|
|
|
|
Deconstruct.UnApply(Deconstruct.IsFunc(Funcs.FirstOf), Deconstruct.Assign(order)),
|
|
|
|
Deconstruct.Assign(mapping)
|
2020-04-30 17:23:44 +02:00
|
|
|
).Invoke(expr))
|
|
|
|
{
|
|
|
|
var expectedKeys = ((IEnumerable<object>) order.First().Evaluate(null)).Select(o =>
|
|
|
|
{
|
|
|
|
if (o is IExpression x)
|
|
|
|
{
|
|
|
|
return (string) x.Evaluate(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (string) o;
|
|
|
|
})
|
|
|
|
.ToHashSet();
|
|
|
|
var actualKeys = mapping.First().PossibleTags().Keys;
|
|
|
|
var missingInOrder = actualKeys.Where(key => !expectedKeys.Contains(key)).ToList();
|
|
|
|
var missingInMapping = expectedKeys.Where(key => !actualKeys.Contains(key)).ToList();
|
|
|
|
if (missingInOrder.Any() || missingInMapping.Any())
|
|
|
|
{
|
|
|
|
var missingInOrderMsg = "";
|
|
|
|
if (missingInOrder.Any())
|
|
|
|
{
|
|
|
|
missingInOrderMsg = $"The order misses keys {string.Join(",", missingInOrder)}\n";
|
|
|
|
}
|
|
|
|
|
|
|
|
var missingInMappingMsg = "";
|
|
|
|
if (missingInMapping.Any())
|
|
|
|
{
|
|
|
|
missingInMappingMsg =
|
|
|
|
$"The mapping misses mappings for keys {string.Join(", ", missingInMapping)}\n";
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new ArgumentException(
|
|
|
|
"Sanity check failed: the specified order of firstMatchOf contains to little or to much keys:\n" +
|
|
|
|
missingInOrderMsg + missingInMappingMsg
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
/// Returns which tags are used in this calculation
|
|
|
|
///
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="e"></param>
|
|
|
|
/// <returns>A dictionary containing all possible values. An entry with an empty list indicates a wildcard</returns>
|
|
|
|
public static Dictionary<string, List<string>> PossibleTags(this IExpression e)
|
|
|
|
{
|
|
|
|
var result = new Dictionary<string, List<string>>();
|
|
|
|
var mappings = new List<Mapping>();
|
|
|
|
e.Visit(x =>
|
|
|
|
{
|
|
|
|
/*
|
|
|
|
var networkMapping = new List<IExpression>();
|
|
|
|
if (Deconstruct.UnApply(
|
|
|
|
IsFunc(Funcs.MustMatch),
|
|
|
|
Assign(networkMapping)
|
|
|
|
).Invoke(x))
|
|
|
|
{
|
|
|
|
var possibleTags = x.PossibleTags();
|
|
|
|
result.
|
|
|
|
return false;
|
|
|
|
}*/
|
|
|
|
|
|
|
|
if (x is Mapping m)
|
|
|
|
{
|
|
|
|
mappings.Add(m);
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (mappings.Count == 0)
|
|
|
|
{
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Visit will have the main mapping at the first position
|
|
|
|
var rootMapping = mappings[0];
|
|
|
|
|
|
|
|
foreach (var (key, expr) in rootMapping.StringToResultFunctions)
|
|
|
|
{
|
|
|
|
var values = new List<string>();
|
|
|
|
expr.Visit(x =>
|
|
|
|
{
|
|
|
|
if (x is Mapping m)
|
|
|
|
{
|
|
|
|
values.AddRange(m.StringToResultFunctions.Keys);
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
result[key] = values;
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
2020-05-02 13:09:49 +02:00
|
|
|
|
|
|
|
public static Dictionary<string, IExpression> MembershipMappingsFor(ProfileMetaData profile, Context context)
|
|
|
|
{
|
|
|
|
var calledFunctions = profile.Priority.Values.ToHashSet();
|
|
|
|
calledFunctions.Add(profile.Speed);
|
|
|
|
calledFunctions.Add(profile.Access);
|
|
|
|
calledFunctions.Add(profile.Oneway);
|
|
|
|
|
|
|
|
|
|
|
|
var calledFunctionQueue = new Queue<string>();
|
|
|
|
var alreadyAnalysedFunctions = new HashSet<string>();
|
|
|
|
var memberships = new Dictionary<string, IExpression>();
|
|
|
|
|
|
|
|
void HandleExpression(IExpression e, string calledIn)
|
|
|
|
{
|
|
|
|
e.Visit(f =>
|
|
|
|
{
|
|
|
|
var mapping = new List<IExpression>();
|
|
|
|
if (Deconstruct.UnApply(Deconstruct.IsFunc(Funcs.MemberOf),
|
|
|
|
Deconstruct.Assign(mapping)
|
|
|
|
).Invoke(f))
|
|
|
|
{
|
|
|
|
memberships.Add(calledIn, mapping.First());
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (f is FunctionCall fc)
|
|
|
|
{
|
|
|
|
calledFunctionQueue.Enqueue(fc.CalledFunctionName);
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
foreach (var e in calledFunctions)
|
|
|
|
{
|
|
|
|
HandleExpression(e, "profile_root");
|
|
|
|
}
|
|
|
|
|
|
|
|
while (calledFunctionQueue.TryDequeue(out var functionName))
|
|
|
|
{
|
|
|
|
if (alreadyAnalysedFunctions.Contains(functionName))
|
|
|
|
{
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
alreadyAnalysedFunctions.Add(functionName);
|
|
|
|
|
|
|
|
var functionImplementation = context.GetFunction(functionName);
|
|
|
|
HandleExpression(functionImplementation, functionName);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return memberships;
|
|
|
|
}
|
2020-04-30 17:23:44 +02:00
|
|
|
}
|
|
|
|
}
|