Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Add varargs to functions, and to_list() function.

to_list is a list constructor function that takes a vararg array and returns
a list of its argument elements; it uses UniversalType args to determine the
return type.
  • Loading branch information...
commit 36c735f4701a0bed92c8a30bed7e6587de58ec7a 1 parent a5726c8
@kimballa kimballa authored
View
12 TODO
@@ -1,5 +1,4 @@
-
most important new features:
-- range intervals over rows, not just time
@@ -14,6 +13,17 @@ Types:
-- Add CHARACTER type which is a single char.
-- need List<T>, Map<T1, T2> types
+ list functions:
+ contains(lst<t> theList, t val) -> true if theList contains X such that X = val
+ index(list<t> theList, INT i) -> returns theList[i]
+ size(list<t>) -> returns ## of elements in the list
+ append(list<t> lst, t val) -> returns lst @ [val]
+ reverse(list<t> lst) -> reverses the order of elements in lst
+ map_fn(fn<t> f, list<t> lst) -> applies f to each value in lst and returns the result list.
+ exists(fn<t> f, list<t> lst) -> true if f returns true for some element of lst
+ foldl(fn<x, t> f, x initial, list<t> lst) -> folds f over elements of lst, using
+ initial as the first accumulator value.
+ concat(list<t> lst1, lst<t> lst2) -> returns lst1 @ lst2.
-- BINARY type should be able to specify encoding when converting to/from STRING.
View
2  bin/flumebase
@@ -170,7 +170,7 @@ if [ \( ! -d "${LIB_DIR}" \) -a \( ! -d "${MVN_BUILD_DEPS_DIR}" \) ]; then
if [ "${ret}" == 0 ]; then
pushd "${projectroot}"
echo "Retrieving dependencies via mvn"
- mvn dependency:copy-dependencies
+ mvn dependency:copy-dependencies --offline
mvnret=$?
if [ "${mvnret}" != 0 ]; then
echo "WARNING: It looks like you're running from a development branch, but"
View
4 src/main/java/com/odiago/flumebase/exec/BuiltInSymbolTable.java
@@ -57,6 +57,7 @@
loadBuiltinFunction(square.class);
loadBuiltinFunction(str2bin.class);
loadBuiltinFunction(sum.class);
+ loadBuiltinFunction(to_list.class);
BUILTINS = Collections.unmodifiableMap(BUILTINS);
}
@@ -107,9 +108,10 @@ private static void loadBuiltinFunction(Class<? extends Function> cls) {
Function fn = (Function) cls.newInstance();
Type retType = fn.getReturnType();
List<Type> argTypes = fn.getArgumentTypes();
+ List<Type> varArgTypes = fn.getVarArgTypes();
String fnName = cls.getSimpleName();
LOG.debug("Loaded built-in function: " + fnName);
- Symbol fnSymbol = new FnSymbol(fnName, fn, retType, argTypes);
+ Symbol fnSymbol = new FnSymbol(fnName, fn, retType, argTypes, varArgTypes);
BUILTINS.put(fnName, fnSymbol);
} catch (InstantiationException ie) {
LOG.error("Could not instantiate class: " + ie);
View
15 src/main/java/com/odiago/flumebase/exec/FnSymbol.java
@@ -31,23 +31,32 @@
/** The types of all the arguments. */
private final List<Type> mArgTypes;
+ /** The types of any varargs. */
+ private final List<Type> mVarArgTypes;
+
/** The return type of the function. */
private final Type mRetType;
/** The function instance itself. */
private final Function mFunc;
- public FnSymbol(String name, Function func, Type retType, List<Type> argTypes) {
- super(name, new FnType(retType, argTypes));
+ public FnSymbol(String name, Function func, Type retType, List<Type> argTypes,
+ List<Type> varArgTypes) {
+ super(name, new FnType(retType, argTypes, varArgTypes));
mFunc = func;
mRetType = retType;
mArgTypes = argTypes;
+ mVarArgTypes = varArgTypes;
}
public List<Type> getArgumentTypes() {
return mArgTypes;
}
+ public List<Type> getVarArgTypes() {
+ return mVarArgTypes;
+ }
+
public Type getReturnType() {
return mRetType;
}
@@ -58,6 +67,6 @@ public Function getFuncInstance() {
@Override
public Symbol withName(String name) {
- return new FnSymbol(name, mFunc, mRetType, mArgTypes);
+ return new FnSymbol(name, mFunc, mRetType, mArgTypes, mVarArgTypes);
}
}
View
69 src/main/java/com/odiago/flumebase/exec/builtins/to_list.java
@@ -0,0 +1,69 @@
+/**
+ * Licensed to Odiago, Inc. under one or more contributor license
+ * agreements. See the NOTICE.txt file distributed with this work for
+ * additional information regarding copyright ownership. Odiago, Inc.
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.odiago.flumebase.exec.builtins;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import com.odiago.flumebase.exec.EventWrapper;
+
+import com.odiago.flumebase.lang.ListType;
+import com.odiago.flumebase.lang.ScalarFunc;
+import com.odiago.flumebase.lang.Type;
+import com.odiago.flumebase.lang.UniversalType;
+
+/**
+ * Return a list containing all the arguments.
+ * This method supports a variable-length argument list.
+ */
+public class to_list extends ScalarFunc {
+ private UniversalType mArgType;
+
+ public to_list() {
+ mArgType = new UniversalType("'a");
+ mArgType.addConstraint(Type.getNullable(Type.TypeName.TYPECLASS_ANY));
+ }
+
+ @Override
+ public Type getReturnType() {
+ return new ListType(mArgType);
+ }
+
+ @Override
+ public Object eval(EventWrapper event, Object... args) {
+ // Listify all the arguments. The FnCallExpr wrapping this should have
+ // already auto-promoted our arguments for us, so we don't have to do
+ // much in here.
+ List<Object> out = new ArrayList<Object>();
+ for (Object arg : args) {
+ out.add(arg);
+ }
+ return out;
+ }
+
+ @Override
+ public List<Type> getArgumentTypes() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List<Type> getVarArgTypes() {
+ return Collections.singletonList((Type) mArgType);
+ }
+}
View
47 src/main/java/com/odiago/flumebase/lang/FnType.java
@@ -17,7 +17,9 @@
package com.odiago.flumebase.lang;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import com.odiago.flumebase.util.StringUtils;
@@ -31,10 +33,14 @@
// The argument types to the function.
private List<Type> mArgTypes;
- public FnType(Type retType, List<Type> argTypes) {
+ // Vararg types
+ private List<Type> mVarArgTypes;
+
+ public FnType(Type retType, List<Type> argTypes, List<Type> varArgTypes) {
super(TypeName.SCALARFUNC);
mRetType = retType;
mArgTypes = argTypes;
+ mVarArgTypes = varArgTypes;
}
public Type getReturnType() {
@@ -45,6 +51,10 @@ public Type getReturnType() {
return mArgTypes;
}
+ public List<Type> getVarArgTypes() {
+ return mVarArgTypes;
+ }
+
@Override
public boolean isNullable() {
return false;
@@ -60,6 +70,13 @@ public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("(");
StringUtils.formatList(sb, mArgTypes);
+
+ if (mVarArgTypes.size() > 0 ) {
+ sb.append(", ");
+ StringUtils.formatList(sb, mVarArgTypes);
+ sb.append("...");
+ }
+
sb.append(") -> ");
sb.append(mRetType);
return sb.toString();
@@ -100,6 +117,34 @@ public boolean equals(Object other) {
}
}
+ if (mVarArgTypes.size() != otherType.mVarArgTypes.size()) {
+ return false;
+ }
+
+ for (int i = 0; i < mVarArgTypes.size(); i++) {
+ if (!mVarArgTypes.get(i).equals(otherType.mVarArgTypes.get(i))) {
+ return false;
+ }
+ }
+
return true;
}
+
+ /** {@inheritDoc} */
+ @Override
+ public Type replaceUniversal(Map<Type, Type> universalMapping) throws TypeCheckException {
+ Type retType = mRetType.replaceUniversal(universalMapping);
+
+ List<Type> argTypes = new ArrayList<Type>();
+ for (Type argType : mArgTypes) {
+ argTypes.add(argType.replaceUniversal(universalMapping));
+ }
+
+ List<Type> varArgTypes = new ArrayList<Type>();
+ for (Type argType : mVarArgTypes) {
+ varArgTypes.add(argType.replaceUniversal(universalMapping));
+ }
+
+ return new FnType(retType, argTypes, varArgTypes);
+ }
}
View
14 src/main/java/com/odiago/flumebase/lang/Function.java
@@ -17,6 +17,7 @@
package com.odiago.flumebase.lang;
+import java.util.Collections;
import java.util.List;
/**
@@ -30,11 +31,22 @@
public abstract Type getReturnType();
/**
- * @return an ordered list containing the types expected for all arguments.
+ * @return an ordered list containing the types expected for all mandatory arguments.
*/
public abstract List<Type> getArgumentTypes();
/**
+ * @return an ordered list containing types expected for variable argument lists.
+ * If a function takes a variable-length argument list, the varargs must be arranged
+ * in groups matching the size of the list returned by this method. e.g., to accept
+ * an arbitrary number of strings, this should return a singleton list of type STRING.
+ * If pairs of strings and ints are required, this should return a list [STRING, INT].
+ */
+ public List<Type> getVarArgTypes() {
+ return Collections.emptyList();
+ }
+
+ /**
* Determines whether arguments are promoted to their specified types by
* the runtime. If this returns true, actual arguments are promoted to
* new values that match the types specified in getArgumentTypes().
View
13 src/main/java/com/odiago/flumebase/lang/ListType.java
@@ -17,6 +17,8 @@
package com.odiago.flumebase.lang;
+import java.util.Map;
+
import org.apache.avro.Schema;
/**
@@ -35,7 +37,6 @@ public ListType(Type elemType) {
this.mElementType = elemType;
assert null != elemType;
- assert elemType.isScalar();
}
public Type getElementType() {
@@ -45,9 +46,9 @@ public Type getElementType() {
@Override
public String toString(boolean isNullable) {
if (isNullable) {
- return "LIST(" + mElementType + ")";
+ return "LIST<" + mElementType + ">";
} else {
- return "LIST(" + mElementType + ") NOT NULL";
+ return "LIST<" + mElementType + "> NOT NULL";
}
}
@@ -89,6 +90,12 @@ public boolean isPrimitive() {
public boolean isConcrete() {
return true;
}
+
+ /** {@inheritDoc} */
+ @Override
+ public Type replaceUniversal(Map<Type, Type> universalMapping) throws TypeCheckException {
+ return new ListType(mElementType.replaceUniversal(universalMapping));
+ }
}
View
11 src/main/java/com/odiago/flumebase/lang/NullableType.java
@@ -19,6 +19,7 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import org.apache.avro.Schema;
@@ -46,6 +47,10 @@ public NullableType(Type type) {
super(TypeName.NULLABLE);
mType = type;
assert null != mType;
+
+ // This is an illegal configuration and will cause Avro to fail
+ // (Cannot nest unions directly.)
+ assert !(mType instanceof NullableType);
}
/** Return the inner type that we're making nullable. */
@@ -101,6 +106,12 @@ public Schema getAvroSchema() {
return Schema.createUnion(unionTypes);
}
+ /** {@inheritDoc} */
+ @Override
+ public Type replaceUniversal(Map<Type, Type> universalMapping) throws TypeCheckException {
+ return new NullableType(mType.replaceUniversal(universalMapping));
+ }
+
@Override
public String toString() {
return mType.toString(true);
View
17 src/main/java/com/odiago/flumebase/lang/Type.java
@@ -53,7 +53,7 @@
BINARY(6),
TIMESTAMP(6),
TIMESPAN(7),
- ANY(0), // 'null' constant can be cast to any type. Only valid inside NULLABLE.
+ ANY(0), // 'null' constant can be cast to any type. Only valid inside NULLABLE or LIST.
// This represents the "bottom" of the promotesTo type lattice.
NULLABLE, // nullable instance of a primitive type (int, bigint, etc).
STREAM, // Collection (record) of named primitive or nullable types.
@@ -568,6 +568,8 @@ public boolean promotesTo(Type other) {
/** @return an Avro schema describing the specified TypeName. */
protected Schema getAvroSchema(TypeName typeName) {
switch (typeName) {
+ case ANY:
+ return Schema.create(Schema.Type.NULL);
case BOOLEAN:
return Schema.create(Schema.Type.BOOLEAN);
case INT:
@@ -592,6 +594,19 @@ protected Schema getAvroSchema(TypeName typeName) {
}
}
+ /**
+ * @return this type structure with any UniversalType instances
+ * replaced by their concrete types.
+ *
+ * @param universalMapping a map from universal type instances to their
+ * concrete replacements within an expression.
+ * @throws TypeCheckException if the universal type is not bound properly.
+ */
+ public Type replaceUniversal(Map<Type, Type> universalMapping) throws TypeCheckException {
+ // Ordinary scalar type, etc. is not universal to begin with.
+ return this;
+ }
+
public String toString(boolean isNullable) {
if (isNullable) {
return mTypeName.name();
View
4 src/main/java/com/odiago/flumebase/lang/TypeCheckException.java
@@ -28,4 +28,8 @@ public TypeCheckException() {
public TypeCheckException(String msg) {
super(msg);
}
+
+ public TypeCheckException(String msg, Throwable cause) {
+ super(msg, cause);
+ }
}
View
19 src/main/java/com/odiago/flumebase/lang/UniversalType.java
@@ -19,6 +19,10 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import com.odiago.flumebase.util.StringUtils;
@@ -49,6 +53,8 @@
* </p>
*/
public class UniversalType extends Type {
+ private static final Logger LOG = LoggerFactory.getLogger(
+ UniversalType.class.getName());
/**
* The set of type(classes) which constrain the set of values this type can
@@ -218,4 +224,17 @@ public boolean equals(Object other) {
return true;
}
+
+ /** {@inheritDoc} */
+ @Override
+ public Type replaceUniversal(Map<Type, Type> universalMapping) throws TypeCheckException {
+ Type replacement = universalMapping.get(this);
+
+ if (null == replacement) {
+ throw new TypeCheckException("No runtime binding for universal type: " + this);
+ }
+
+ LOG.debug("Resolved arg type from " + this + " to " + replacement);
+ return replacement;
+ }
}
View
79 src/main/java/com/odiago/flumebase/parser/FnCallExpr.java
@@ -36,6 +36,7 @@
import com.odiago.flumebase.lang.AggregateFunc;
import com.odiago.flumebase.lang.EvalException;
import com.odiago.flumebase.lang.Function;
+import com.odiago.flumebase.lang.ListType;
import com.odiago.flumebase.lang.ScalarFunc;
import com.odiago.flumebase.lang.Type;
import com.odiago.flumebase.lang.TypeCheckException;
@@ -166,14 +167,42 @@ public void resolveArgTypes(SymbolTable symTab) throws TypeCheckException {
// Get a list of argument types from the function symbol. These may include
// universal types we need to concretize.
- List<Type> abstractArgTypes = mFnSymbol.getArgumentTypes();
+ List<Type> abstractArgTypes = new ArrayList<Type>(mFnSymbol.getArgumentTypes());
- if (mArgExprs.size() != abstractArgTypes.size()) {
- // Check that arity matches.
+ // Argument types for varargs, after the fixed args.
+ List<Type> abstractVarArgTypes = mFnSymbol.getVarArgTypes();
+
+ // Check that arity matches.
+ int argsRemaining = mArgExprs.size();
+ argsRemaining -= abstractArgTypes.size();
+ if (argsRemaining < 0 || (argsRemaining > 0 && abstractVarArgTypes.size() == 0)) {
+ // Too few actual args, or too many args (and this is not a varargs fn).
throw new TypeCheckException("Function " + mFunctionName + " requires "
+ abstractArgTypes.size() + " arguments, but received " + mArgExprs.size());
}
+ if (argsRemaining > 0 && abstractVarArgTypes.size() > 0) {
+ // varargs may need to come in pairs, etc. Check that we have a correct multiple
+ // of the number of varargs available.
+ int argRemainder = argsRemaining % abstractVarArgTypes.size();
+ if (0 != argRemainder) {
+ throw new TypeCheckException("Function " + mFunctionName + " requires varargs "
+ + "in sets of " + abstractVarArgTypes.size() + ", but this call has "
+ + argRemainder + " too few.");
+ }
+ }
+
+ // For each actual vararg, add its type to the abstractArgTypes list.
+ if (abstractVarArgTypes.size() > 0) {
+ int numVarArgSets = (mArgExprs.size() - abstractArgTypes.size())
+ / abstractVarArgTypes.size();
+ for (int i = 0; i < numVarArgSets; i++) {
+ abstractArgTypes.addAll(abstractVarArgTypes);
+ }
+ }
+
+ assert mArgExprs.size() == abstractArgTypes.size();
+
// Check that each expression type can promote to the argument type.
for (int i = 0; i < mArgExprs.size(); i++) {
Type exprType = mArgExprs.get(i).getType(symTab);
@@ -185,7 +214,7 @@ public void resolveArgTypes(SymbolTable symTab) throws TypeCheckException {
}
}
- mArgTypes = new Type[abstractArgTypes.size()];
+ mArgTypes = new Type[mArgExprs.size()];
// Now identify all the UniversalType instances in here, and the
// actual constraints on each of these.
@@ -217,41 +246,33 @@ public void resolveArgTypes(SymbolTable symTab) throws TypeCheckException {
// Finally, generate a list of concrete argument types for coercion purposes.
for (int i = 0; i < abstractArgTypes.size(); i++ ) {
Type abstractType = abstractArgTypes.get(i);
- if (abstractType instanceof UniversalType) {
- // Use the resolved type instead.
- mArgTypes[i] = unificationOut.get(abstractType);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Resolved arg[" + i + "] type of " + mFunctionName + " from "
- + abstractType + " to " + mArgTypes[i]);
- }
- } else {
- // Use the specified literal type from the function definition.
- mArgTypes[i] = abstractType;
- }
-
- assert(mArgTypes[i] != null);
+ mArgTypes[i] = abstractType.replaceUniversal(unificationOut);
+ assert mArgTypes[i] != null;
}
// Also set mReturnType; if this referenced a UniversalType, use the resolved
// version. Otherwise, use the version from the function directly.
Type fnRetType = mFnSymbol.getReturnType();
- if (fnRetType instanceof UniversalType) {
- mReturnType = unificationOut.get(fnRetType);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Resolved return type of " + mFunctionName + " from " + fnRetType
- + " to " + mReturnType);
- }
- if (null == mReturnType) {
- // We can only resolve against our arguments, not our caller's type.
+ try {
+ mReturnType = fnRetType.replaceUniversal(unificationOut);
+ } catch (TypeCheckException tce) {
+ // We can only resolve against our arguments, not our caller's type.
+ if (fnRetType instanceof ListType) {
+ // If the unresolved typevar is an argument to a list type, we can
+ // return this -- it's going to be an empty list, so we can return
+ // LIST<ANY>
+ // TODO(aaron): This allows to_list() to produce an empty list, but
+ // putting this check here feels a bit like a hack to me.
+ mReturnType = new ListType(Type.getPrimitive(Type.TypeName.ANY));
+ } else {
// This fails for being too abstract.
throw new TypeCheckException("Output type of function " + mFunctionName
- + " is an unresolved UniversalType: " + fnRetType);
+ + " is an unresolved UniversalType: " + fnRetType, tce);
}
- } else {
- // Normal type; use directly.
- mReturnType = fnRetType;
}
+ assert null != mReturnType;
+
mExecFunc = mFnSymbol.getFuncInstance();
mAutoPromote = mExecFunc.autoPromoteArguments();
mPartialResults = new Object[mExprTypes.size()];
View
81 src/test/java/com/odiago/flumebase/exec/TestFunctions.java
@@ -20,6 +20,7 @@
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import org.apache.avro.generic.GenericData;
@@ -97,6 +98,47 @@ public Type getReturnType() {
}
/**
+ * Var-arg function that concatenates a bunch of strings.
+ */
+ private static class concatstrs extends ScalarFunc {
+
+ public concatstrs() {
+ }
+
+ @Override
+ public Object eval(EventWrapper event, Object... args) {
+ StringBuilder sb = new StringBuilder();
+
+ for (Object arg : args) {
+ if (null == arg) {
+ sb.append("null");
+ } else {
+ assert arg instanceof CharSequence;
+ sb.append(arg.toString());
+ }
+ }
+
+ return new Utf8(sb.toString());
+ }
+
+ @Override
+ public Type getReturnType() {
+ return Type.getPrimitive(Type.TypeName.STRING);
+ }
+
+ @Override
+ public List<Type> getArgumentTypes() {
+ // No required args.
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List<Type> getVarArgTypes() {
+ return Collections.singletonList(Type.getNullable(Type.TypeName.STRING));
+ }
+ }
+
+ /**
* Run a test where one records of two integer-typed fields is selected from
* two input records.
* @param query the query string to submit to the execution engine.
@@ -117,7 +159,12 @@ private void runFnTest(String query, List<Pair<String, Object>> checkFields)
// Register the 'max2' function we use in some tests.
ScalarFunc max2Func = new max2();
getSymbolTable().addSymbol(new FnSymbol("max2", max2Func, max2Func.getReturnType(),
- max2Func.getArgumentTypes()));
+ max2Func.getArgumentTypes(), max2Func.getVarArgTypes()));
+
+ // Register the 'concatstrs' function we use in some tests.
+ ScalarFunc strcatFunc = new concatstrs();
+ getSymbolTable().addSymbol(new FnSymbol("concatstrs", strcatFunc, strcatFunc.getReturnType(),
+ strcatFunc.getArgumentTypes(), strcatFunc.getVarArgTypes()));
getConf().set(SelectStmt.CLIENT_SELECT_TARGET_KEY, "testSelect");
@@ -319,4 +366,36 @@ public void testDoubleUnification4() throws IOException, InterruptedException {
}
}
+ @Test
+ public void testVarArgs1() throws Exception {
+ // Test that we can put a single value into a vararg fn.
+ List<Pair<String, Object>> checks = new ArrayList<Pair<String, Object>>();
+ checks.add(new Pair<String, Object>("x", new Utf8("foo")));
+ runFnTest("SELECT concatstrs('foo') AS x FROM memstream", checks);
+ }
+
+ @Test
+ public void testVarArgsMulti() throws Exception {
+ // Test that we can put a few values into a vararg fn.
+ List<Pair<String, Object>> checks = new ArrayList<Pair<String, Object>>();
+ checks.add(new Pair<String, Object>("x", new Utf8("foobarbaz")));
+ runFnTest("SELECT concatstrs('foo', 'bar', 'baz') AS x FROM memstream", checks);
+ }
+
+ @Test
+ public void testVarArgsEmpty() throws Exception {
+ // Test that we can put no values into a vararg fn.
+ List<Pair<String, Object>> checks = new ArrayList<Pair<String, Object>>();
+ checks.add(new Pair<String, Object>("x", new Utf8("")));
+ runFnTest("SELECT concatstrs() AS x FROM memstream", checks);
+ }
+
+ @Test
+ public void testVarArgsCoerce() throws Exception {
+ // Test that we can put values of different types into a vararg fn,
+ // and they are all coerced to the correct type.
+ List<Pair<String, Object>> checks = new ArrayList<Pair<String, Object>>();
+ checks.add(new Pair<String, Object>("x", new Utf8("foo42")));
+ runFnTest("SELECT concatstrs('foo', 42) AS x FROM memstream", checks);
+ }
}
Please sign in to comment.
Something went wrong with that request. Please try again.