diff --git a/sqrl-cli/src/main/java/com/datasqrl/compile/GqlGenerator.java b/sqrl-cli/src/main/java/com/datasqrl/compile/GqlGenerator.java index 8c594a0fbb..0cc187d0df 100644 --- a/sqrl-cli/src/main/java/com/datasqrl/compile/GqlGenerator.java +++ b/sqrl-cli/src/main/java/com/datasqrl/compile/GqlGenerator.java @@ -74,7 +74,12 @@ private List processQueryDefinition(ObjectTypeDefinition definition, Docum List queries = new ArrayList<>(); var defs = definition.getFieldDefinitions(); for (FieldDefinition def : defs) { - final var tableFn = getTableFunctionFromPath(tableFunctions, def.getName()).get(); + // Namespace fields (e.g. `backend`) have no table function at the root path; skip them. + final var tableFnOpt = getTableFunctionFromPath(tableFunctions, def.getName()); + if (tableFnOpt.isEmpty()) { + continue; + } + final var tableFn = tableFnOpt.get(); if (tableFn.getVisibility().isTest()) { var operation = processOperation( diff --git a/sqrl-planner/src/main/java/com/datasqrl/planner/NamespaceDefinition.java b/sqrl-planner/src/main/java/com/datasqrl/planner/NamespaceDefinition.java new file mode 100644 index 0000000000..700ee1b23b --- /dev/null +++ b/sqrl-planner/src/main/java/com/datasqrl/planner/NamespaceDefinition.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2021 DataSQRL (contact@datasqrl.com) + * + * Licensed 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.datasqrl.planner; + +import com.datasqrl.canonicalizer.Name; +import com.datasqrl.server.ResolvedMetadata; +import java.util.List; +import java.util.Optional; +import org.apache.calcite.rel.type.RelDataType; + +/** + * A {@code CREATE NAMESPACE} declaration: a name plus the parameters shared by every function in + * the namespace. A parameter with metadata is a hidden JWT claim; a parameter without metadata is + * an external argument exposed on the namespace field (e.g. {@code admin(asTenantId: String!)}). + */ +public record NamespaceDefinition(Name name, List params) { + + public record Param(String name, RelDataType type, Optional metadata) { + public boolean isExternal() { + return metadata.isEmpty(); + } + } + + public Optional getParam(String paramName) { + return params.stream().filter(p -> p.name().equalsIgnoreCase(paramName)).findFirst(); + } + + public List externalParams() { + return params.stream().filter(Param::isExternal).toList(); + } +} diff --git a/sqrl-planner/src/main/java/com/datasqrl/planner/SqlScriptPlanner.java b/sqrl-planner/src/main/java/com/datasqrl/planner/SqlScriptPlanner.java index fbe953d474..7d0e624234 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/planner/SqlScriptPlanner.java +++ b/sqrl-planner/src/main/java/com/datasqrl/planner/SqlScriptPlanner.java @@ -80,6 +80,7 @@ import com.datasqrl.planner.parser.SQLStatement; import com.datasqrl.planner.parser.SqlScriptStatementSplitter; import com.datasqrl.planner.parser.SqrlAddColumnStatement; +import com.datasqrl.planner.parser.SqrlCreateNamespaceStatement; import com.datasqrl.planner.parser.SqrlCreateTableStatement; import com.datasqrl.planner.parser.SqrlDefinition; import com.datasqrl.planner.parser.SqrlExportStatement; @@ -95,6 +96,7 @@ import com.datasqrl.planner.parser.StatementParserException; import com.datasqrl.planner.tables.AccessVisibility; import com.datasqrl.planner.tables.FlinkTableBuilder; +import com.datasqrl.planner.tables.SqrlFunctionParameter; import com.datasqrl.planner.tables.SqrlTableFunction; import com.datasqrl.planner.util.SqlScriptWriter; import com.datasqrl.planner.util.SqlTableNameExtractor; @@ -115,6 +117,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.apache.calcite.rel.type.RelDataType; import org.apache.calcite.rel.type.RelDataTypeField; import org.apache.calcite.schema.FunctionParameter; @@ -144,6 +147,7 @@ */ @Component @Lazy +@Slf4j public class SqlScriptPlanner { private static final String EXPORT_SUFFIX = "_ex"; @@ -151,6 +155,9 @@ public class SqlScriptPlanner { private final AtomicInteger exportTableCounter = new AtomicInteger(0); + /** Declared namespaces (grouping + shared parameters), keyed by namespace name. */ + private final Map namespaces = new HashMap<>(); + private final ErrorCollector errorCollector; /** Used to assemble the full script with imports as a string */ @@ -279,7 +286,8 @@ public void planMain( throw lineErrors.handle(e); } - if (!(sqlStatement instanceof SqrlImportStatement)) { + if (!(sqlStatement instanceof SqrlImportStatement) + && !(sqlStatement instanceof SqrlCreateNamespaceStatement)) { completeScript.append(sourceStmt.source()); } @@ -339,6 +347,8 @@ private void planStatement( scriptContext.mainModuleLoader().getSchemaLoader(), hintsAndDocs) .ifPresent(tableAnalysis -> addSourceToDag(tableAnalysis, hintsAndDocs, sqrlEnv)); + } else if (stmt instanceof SqrlCreateNamespaceStatement nsStmt) { + addNamespace(nsStmt, sqrlEnv, errors); } else if (stmt instanceof SqrlDefinition sqrlDef) { var access = sqrlDef.getAccess(); var tablePath = sqrlDef.getPath(); @@ -411,30 +421,45 @@ private void planStatement( }); } TableAnalysis parentTbl = null; + var namespaced = false; + final Name namespaceName = + tblFnStmt.isRelationship() ? tblFnStmt.getPath().getFirst() : null; + NamespaceDefinition namespaceDef = null; if (tblFnStmt.isRelationship()) { - /* To resolve the arguments and get their type, we first need to look up the parent table + /* A size-2 path is either a relationship on an existing table or a namespaced root + function grouped under a namespace (e.g. `backend.dormantDeployments`). If the head + resolves to a table it is a relationship; if it resolves to nothing it is a namespace. */ var parentNode = dagBuilder.getNode(identifier); - checkFatal( - parentNode.isPresent(), - sqrlDef.getTableName().getFileLocation(), - ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, - "Could not find parent table for relationship: %s", - tblFnStmt.getPath().getFirst()); - checkFatal( - parentNode.get() instanceof TableNode, - sqrlDef.getTableName().getFileLocation(), - ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, - "Relationships can only be added to tables (not functions): %s [%s]", - tblFnStmt.getPath().getFirst(), - parentNode.get().getClass()); identifier = scriptContext.toIdentifier(tablePath.toString()); - parentTbl = ((TableNode) parentNode.get()).getTableAnalysis(); - checkFatal( - parentTbl.getOptionalBaseTable().isEmpty(), - ErrorCode.BASETABLE_ONLY_ERROR, - "Relationships can only be added to the base table [%s]", - parentTbl.getBaseTable().getIdentifier()); + if (parentNode.isEmpty()) { + namespaced = true; + namespaceDef = namespaces.get(namespaceName); + checkFatal( + tblFnStmt.getArgumentsByIndex().stream().noneMatch(ParsedArgument::isParentField), + sqrlDef.getTableName().getFileLocation(), + ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, + "Namespaced function [%s] cannot reference `this`. It has no parent table.", + tblFnStmt.getPath()); + log.info( + "Exposing [{}] under GraphQL namespace [{}]", + tblFnStmt.getPath(), + tblFnStmt.getPath().getFirst()); + } else { + checkFatal( + parentNode.get() instanceof TableNode, + sqrlDef.getTableName().getFileLocation(), + ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, + "Relationships can only be added to tables (not functions): %s [%s]", + tblFnStmt.getPath().getFirst(), + parentNode.get().getClass()); + parentTbl = ((TableNode) parentNode.get()).getTableAnalysis(); + checkFatal( + parentTbl.getOptionalBaseTable().isEmpty(), + ErrorCode.BASETABLE_ONLY_ERROR, + "Relationships can only be added to the base table [%s]", + parentTbl.getBaseTable().getIdentifier()); + } } // Resolve arguments, map indexes, and check for errors Map argumentIndexMap = new HashMap<>(); @@ -455,7 +480,38 @@ private void planStatement( fieldName, argIndex.withResolvedType(field.getType(), arguments.size())); } } - var signatureArg = arguments.get(Name.system(argIndex.getName().get())); + var argName = argIndex.getName().get(); + // References to a namespace parameter use a `.` prefix (e.g. + // `:admin.asTenantId`). + // External namespace params are bound as parent-fields from the namespace field; claims + // as + // metadata. Both are inherited automatically from the CREATE NAMESPACE declaration. + var namespaceParam = namespaceParamName(argName, namespaceName); + Name lookupKey; + if (namespaceParam.isPresent()) { + var paramName = namespaceParam.get(); + checkFatal( + namespaceDef != null, + argIndex.getName().getFileLocation(), + ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, + "Namespace [%s] is not declared. Declare it with CREATE NAMESPACE.", + namespaceName); + var param = namespaceDef.getParam(paramName); + checkFatal( + param.isPresent(), + argIndex.getName().getFileLocation(), + ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, + "Namespace [%s] has no parameter [%s]", + namespaceName, + paramName); + lookupKey = Name.system(paramName); + if (!arguments.containsKey(lookupKey)) { + arguments.put(lookupKey, namespaceParamToArgument(param.get(), arguments.size())); + } + } else { + lookupKey = Name.system(argName); + } + var signatureArg = arguments.get(lookupKey); checkFatal( signatureArg != null, argIndex.getName().getFileLocation(), @@ -513,6 +569,10 @@ private void planStatement( errors); } fnBuilder.fullPath(tblFnStmt.getPath()); + fnBuilder.namespaced(namespaced); + if (namespaceDef != null) { + fnBuilder.namespaceArguments(namespaceFieldArguments(namespaceDef)); + } var visibility = new AccessVisibility( access, hints.isTest(), tblFnStmt.isRelationship() || passthroughFn, isHidden); @@ -596,6 +656,84 @@ private boolean shouldExcludeTestTable(PlannerHints hints) { return hints.isTest() && executionGoal != ExecutionGoal.TEST; } + /** + * Validates and registers a {@code CREATE NAMESPACE} declaration. Each parameter is either an + * external argument (exposed on the namespace field) or a hidden metadata claim (with {@code + * METADATA FROM}). + */ + private void addNamespace( + SqrlCreateNamespaceStatement nsStmt, Sqrl2FlinkSQLTranslator sqrlEnv, ErrorCollector errors) { + var name = nsStmt.getName().get(); + errors.checkFatal( + !namespaces.containsKey(name), + ErrorCode.INVALID_SQRL_DEFINITION, + "Namespace [%s] is already defined", + name); + List params = new ArrayList<>(); + for (var parsedField : sqrlEnv.parse2RelDataType(nsStmt.getParams())) { + var field = parsedField.field(); + var metadata = + parsedField + .metadata() + .map( + metaStr -> { + var resolved = + SqrlTableFunctionStatement.parseMetadata( + metaStr, !field.getType().isNullable()); + errors.checkFatal( + resolved.isPresent(), + ErrorCode.INVALID_TABLE_FUNCTION_ARGUMENTS, + "Invalid metadata key provided: %s", + metaStr); + return resolved.get(); + }); + params.add(new NamespaceDefinition.Param(field.getName(), field.getType(), metadata)); + } + namespaces.put(name, new NamespaceDefinition(name, params)); + } + + /** + * If {@code argName} is a reference to a parameter of the given namespace (i.e. {@code + * .}), returns the bare parameter name. + */ + private static Optional namespaceParamName(String argName, Name namespaceName) { + if (namespaceName == null) { + return Optional.empty(); + } + var prefix = namespaceName.getDisplay() + "."; + if (argName.regionMatches(true, 0, prefix, 0, prefix.length())) { + return Optional.of(argName.substring(prefix.length())); + } + return Optional.empty(); + } + + /** + * Builds the function parameter injected into a namespaced function for a namespace parameter: an + * external parameter is a parent-field (bound from the namespace field's argument), a claim is + * metadata (bound from the JWT). + */ + private static ParsedArgument namespaceParamToArgument( + NamespaceDefinition.Param param, int index) { + return new ParsedArgument( + new ParsedObject<>(param.name(), FileLocation.START), + param.type(), + param.metadata(), + Optional.empty(), + param.isExternal(), + index); + } + + /** The external namespace parameters exposed as arguments on the namespace field. */ + private static List namespaceFieldArguments(NamespaceDefinition namespaceDef) { + var external = namespaceDef.externalParams(); + List args = new ArrayList<>(); + for (var i = 0; i < external.size(); i++) { + var param = external.get(i); + args.add(new SqrlFunctionParameter(param.name(), i, param.type())); + } + return args; + } + /** * Adjusts the access for functions and tables based on the available stages and configuration We * might consider throwing an exception for SUBSCRIPTION access when no subscription stages are diff --git a/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlCreateNamespaceStatement.java b/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlCreateNamespaceStatement.java new file mode 100644 index 0000000000..c7a85ddb51 --- /dev/null +++ b/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlCreateNamespaceStatement.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2021 DataSQRL (contact@datasqrl.com) + * + * Licensed 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.datasqrl.planner.parser; + +import com.datasqrl.canonicalizer.Name; +import com.datasqrl.error.ErrorLocation.FileLocation; +import lombok.Value; + +/** + * Represents a {@code CREATE NAMESPACE ( );} statement that groups queries and + * mutations under a GraphQL sub-object (e.g. {@code backend { ... }}) and declares parameters + * shared by every function in the namespace. A parameter with {@code METADATA FROM 'auth....'} is a + * hidden JWT claim; a parameter without it becomes an argument exposed on the namespace field + * itself. Functions join the namespace via the {@code .func} path prefix and reference the + * shared parameters as {@code :.}. + */ +@Value +public class SqrlCreateNamespaceStatement implements SqrlStatement { + + ParsedObject name; + ParsedObject params; + SqrlComments comments; + + @Override + public FileLocation getDefaultLocation() { + return name.getFileLocation(); + } +} diff --git a/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlStatementParser.java b/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlStatementParser.java index 5b630ace10..b4097237d3 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlStatementParser.java +++ b/sqrl-planner/src/main/java/com/datasqrl/planner/parser/SqrlStatementParser.java @@ -63,6 +63,11 @@ public class SqrlStatementParser { + ")\\s*(\\((?.*?)\\))?\\s*(?:RETURNS\\s*\\((?.*?)\\)\\s*)?:=)"; public static final String CREATE_TABLE_REGEX = BEGINNING_COMMENT + "(?create\\s+(temporary\\s+)?table)"; + public static final String CREATE_NAMESPACE_REGEX = + BEGINNING_COMMENT + + "create\\s+namespace\\s+(?" + + IDENTIFIER_REGEX + + ")\\s*\\((?.*)\\)\\s*;?\\s*"; public static final Pattern IMPORT_PARSER = Pattern.compile( @@ -87,6 +92,8 @@ public class SqrlStatementParser { Pattern.compile(SQRL_DEFINITION_REGEX, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); private static final Pattern CREATE_TABLE = Pattern.compile(CREATE_TABLE_REGEX, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern CREATE_NAMESPACE = + Pattern.compile(CREATE_NAMESPACE_REGEX, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); public static final String DISTINCT_REGEX = "DISTINCT\\s+(?" @@ -106,7 +113,7 @@ public class SqrlStatementParser { public static final String VARIABLE_REGEX = "(?\\W)(?:|@|" + SELF_REFERENCE_KEYWORD - + "\\.)((?\\w+)|`(?[^` ]+)`)"; + + "\\.)((?\\w+(?:\\.\\w+)?)|`(?[^` ]+)`)"; public static final Pattern VARIABLE_PARSER = Pattern.compile(VARIABLE_REGEX, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); @@ -288,7 +295,27 @@ public SQLStatement parseStatement(String statement) { return definition; } - // #3: Create Table + // #3: Create Namespace (grouping + shared parameters) + var createNamespace = CREATE_NAMESPACE.matcher(statement); + if (createNamespace.matches()) { + var name = parseName(createNamespace, "name", statement); + checkFatal( + name.isPresent(), + name.getFileLocation(), + ErrorCode.INVALID_SQRL_DEFINITION, + "Invalid name for namespace"); + var params = + parse(createNamespace, "params", statement).map(str -> str.isBlank() ? null : str); + checkFatal( + !params.isEmpty(), + params.getFileLocation(), + ErrorCode.INVALID_SQRL_DEFINITION, + "Namespace [%s] must declare at least one parameter", + name.get()); + return new SqrlCreateNamespaceStatement(name, params, SqrlComments.EMPTY); + } + + // #4: Create Table var createTable = CREATE_TABLE.matcher(statement); if (createTable.find()) { var createTableStmt = diff --git a/sqrl-planner/src/main/java/com/datasqrl/planner/tables/SqrlTableFunction.java b/sqrl-planner/src/main/java/com/datasqrl/planner/tables/SqrlTableFunction.java index 80c4f7949c..29c0ce966b 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/planner/tables/SqrlTableFunction.java +++ b/sqrl-planner/src/main/java/com/datasqrl/planner/tables/SqrlTableFunction.java @@ -93,6 +93,22 @@ public class SqrlTableFunction implements TableFunction, TableOrFunctionAnalysis */ @Default private boolean passthrough = false; + /** + * Whether this is a namespaced root function. A namespaced function has a path of size 2 (e.g. + * {@code backend.dormantDeployments}) where the head is a namespace (not a table), so it is + * exposed as a field on a namespace object type under the root Query rather than as a + * relationship. + */ + @Default private boolean namespaced = false; + + /** + * External arguments exposed on the enclosing namespace field itself (e.g. {@code asTenantId} in + * {@code admin(asTenantId: String!)}). Shared by all functions in the namespace and propagated to + * each as parent-field parameters. Empty for non-namespaced functions or namespaces with no + * external parameters. + */ + @Default private final List namespaceArguments = List.of(); + @Override public RelDataType getRowType( RelDataTypeFactory relDataTypeFactory, List list) { @@ -123,7 +139,7 @@ public Type getElementType(List list) { } public boolean isRelationship() { - return fullPath.size() > 1; + return fullPath.size() > 1 && !namespaced; } @Override diff --git a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java index 9c6e42e9bc..8ef6214de2 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java +++ b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java @@ -101,7 +101,10 @@ protected void visitSubscription( @Override protected void visitMutation( - FieldDefinition atField, TypeDefinitionRegistry registry, MutationTable mutation) { + ObjectTypeDefinition parentType, + FieldDefinition atField, + TypeDefinitionRegistry registry, + MutationTable mutation) { var computedCols = mutation.getComputedColumns(); var returnList = GraphqlSchemaUtil.isListType(atField.getType()); @@ -115,13 +118,24 @@ protected void visitMutation( mutationTopic.messageKeys(), computedCols, mutation.getInsertType() == MutationInsertType.TRANSACTION, - Map.of())); + Map.of(), + parentType == null ? null : parentType.getName())); } else { throw new RuntimeException( "Unsupported mutation implementation: " + mutation.getCreateTable()); } } + @Override + protected void visitQueryNamespace( + ObjectTypeDefinition parentType, FieldDefinition atField, TypeDefinitionRegistry registry) { + queryCoords.add( + RootGraphQLModel.StaticQueryCoords.builder() + .parentType(parentType.getName()) + .fieldName(atField.getName()) + .build()); + } + @Override protected void visitUnknownObject(FieldDefinition atField, Optional relDataType) {} diff --git a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaFactory.java b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaFactory.java index cfe46976b2..e6b218cc8f 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaFactory.java +++ b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaFactory.java @@ -55,6 +55,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.calcite.rel.type.RelDataTypeField; @@ -277,6 +278,9 @@ private Optional createRootType( List fields = new ArrayList<>(); for (SqrlTableFunction tableFunction : rootTableFunctions) { + if (tableFunction.isNamespaced()) { + continue; // namespaced functions are grouped separately below + } var tableFunctionName = tableFunction.getFullPath().getDisplay(); if (!isValidGraphQLName(tableFunctionName)) { continue; @@ -298,6 +302,9 @@ private Optional createRootType( tableFunction.getDocumentation().getDocStringOpt().ifPresent(fieldBuilder::description); fields.add(fieldBuilder.build()); } + + createNamespaceFields(rootTableFunctions).forEach(fields::add); + if (fields.isEmpty()) { return Optional.empty(); } @@ -312,6 +319,57 @@ private Optional createRootType( return Optional.of(rootQueryObjectType); } + /** + * Groups namespaced root functions (path size 2, e.g. {@code backend.dormantDeployments}) under a + * namespace object type (e.g. {@code BackendQueries}) and returns one field per namespace on the + * root Query type (e.g. {@code backend: BackendQueries}). The namespace object types themselves + * are added to {@link #objectTypes}. + */ + private List createNamespaceFields( + List rootTableFunctions) { + Map> byNamespace = + rootTableFunctions.stream() + .filter(SqrlTableFunction::isNamespaced) + .collect( + Collectors.groupingBy( + fn -> fn.getFullPath().getFirst().getDisplay(), + LinkedHashMap::new, + Collectors.toList())); + + List namespaceFields = new ArrayList<>(); + for (var entry : byNamespace.entrySet()) { + var namespace = entry.getKey(); + if (!isValidGraphQLName(namespace)) { + continue; + } + List nsFields = new ArrayList<>(); + for (SqrlTableFunction fn : entry.getValue()) { + createRelationshipField(fn).map(nsFields::add); + } + if (nsFields.isEmpty()) { + continue; + } + var nsTypeName = namespaceTypeName(namespace); + if (definedTypeNames.add(nsTypeName)) { + objectTypes.add(GraphQLObjectType.newObject().name(nsTypeName).fields(nsFields).build()); + } + // All functions in a namespace share the same namespace-level arguments (e.g. asTenantId), + // which are exposed on the namespace field itself. + var namespaceArgs = entry.getValue().get(0).getNamespaceArguments(); + namespaceFields.add( + GraphQLFieldDefinition.newFieldDefinition() + .name(namespace) + .type(new GraphQLTypeReference(nsTypeName)) + .arguments(buildParameterArguments(namespaceArgs, name -> Optional.empty())) + .build()); + } + return namespaceFields; + } + + private static String namespaceTypeName(String namespace) { + return Character.toUpperCase(namespace.charAt(0)) + namespace.substring(1) + "Queries"; + } + /** * Create a non-relationship field : - a scalar type - a nested relDataType (= structured type) * (which is no more planed as a table function) and which we recursively process @@ -366,34 +424,7 @@ private List createArguments(SqrlTableFunction tableFunction) { .toList(); final List parametersArguments = - parameters.stream() - .filter( - p -> - GraphqlSchemaUtil.getGraphQLInputType( - p.getType(null), - NamePath.of(p.getName()), - extendedScalarTypes, - Documented.NO_LOOKUP) - .isPresent()) - .map( - parameter -> { - var builder = - GraphQLArgument.newArgument() - .name(parameter.getName()) - .type( - GraphqlSchemaUtil.getGraphQLInputType( - parameter.getType(null), - NamePath.of(parameter.getName()), - extendedScalarTypes, - Documented.NO_LOOKUP) - .get()); - tableFunction - .getDocumentation() - .getArgumentOpt(parameter.getName()) - .ifPresent(builder::description); - return builder.build(); - }) - .collect(Collectors.toList()); + buildParameterArguments(parameters, tableFunction.getDocumentation()::getArgumentOpt); List limitAndOffsetArguments = List.of(); if (tableFunction.getVisibility().access() != AccessModifier.SUBSCRIPTION && tableFunction.getMultiplicity() == Multiplicity.MANY) { @@ -402,6 +433,35 @@ private List createArguments(SqrlTableFunction tableFunction) { return ListUtils.union(parametersArguments, limitAndOffsetArguments); } + private List buildParameterArguments( + List parameters, Function> doc) { + return parameters.stream() + .filter( + p -> + GraphqlSchemaUtil.getGraphQLInputType( + p.getType(null), + NamePath.of(p.getName()), + extendedScalarTypes, + Documented.NO_LOOKUP) + .isPresent()) + .map( + parameter -> { + var builder = + GraphQLArgument.newArgument() + .name(parameter.getName()) + .type( + GraphqlSchemaUtil.getGraphQLInputType( + parameter.getType(null), + NamePath.of(parameter.getName()), + extendedScalarTypes, + Documented.NO_LOOKUP) + .get()); + doc.apply(parameter.getName()).ifPresent(builder::description); + return builder.build(); + }) + .collect(Collectors.toList()); + } + private List generateLimitAndOffsetArguments(SqrlTableFunction tableFunction) { var limitValue = tableFunction.getLimit().orElse(defaultLimit); var limit = diff --git a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaValidator.java b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaValidator.java index 9bb4026ee4..090a605b49 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaValidator.java +++ b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaValidator.java @@ -79,9 +79,18 @@ protected void visitSubscription( checkArgumentsMatchParameters(atField, tableFunction, registry); } + @Override + protected void visitQueryNamespace( + ObjectTypeDefinition parentType, FieldDefinition atField, TypeDefinitionRegistry registry) { + // structural checks (object type, no arguments) are performed by the walker + } + @Override protected void visitMutation( - FieldDefinition atField, TypeDefinitionRegistry registry, MutationTable mutation) { + ObjectTypeDefinition parentType, + FieldDefinition atField, + TypeDefinitionRegistry registry, + MutationTable mutation) { validateStructurallyEqualMutation( atField, getValidMutationOutputType(atField, registry), diff --git a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaWalker.java b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaWalker.java index 6900f5ee91..ec17cef6d2 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaWalker.java +++ b/sqrl-planner/src/main/java/com/datasqrl/server/GraphqlSchemaWalker.java @@ -85,15 +85,42 @@ private void walkRootMutationType( "Empty root object type: %s", rootType.getName()); for (FieldDefinition field : rootType.getFieldDefinitions()) { + var mutationQuery = findMutation(field.getName()); + if (mutationQuery.isPresent()) { + // null parentType denotes the root Mutation type (resolved at runtime from the schema) + visitMutation(null, field, registry, mutationQuery.get()); + } else { + walkMutationNamespace(rootType, field, registry); + } + } + } + + /** + * A mutation namespace field groups mutations under an object type. Its sub-fields each map to a + * mutation by name. Only expressible via a user-supplied schema (there is no SQRL syntax to + * path-name a mutation table). + */ + private void walkMutationNamespace( + ObjectTypeDefinition rootType, FieldDefinition nsField, TypeDefinitionRegistry registry) { + var nsType = resolveNamespaceType(nsField, registry); + visitQueryNamespace(rootType, nsField, registry); + for (FieldDefinition subField : nsType.getFieldDefinitions()) { var mutationQuery = - mutations.stream() - .filter(mutation -> mutation.getName().getDisplay().equalsIgnoreCase(field.getName())) - .findFirst() - .orElseThrow(() -> new RuntimeException("No mutation found for " + field.getName())); - visitMutation(field, registry, mutationQuery); + findMutation(subField.getName()) + .orElseThrow( + () -> + new RuntimeException( + "No mutation found for " + nsField.getName() + "." + subField.getName())); + visitMutation(nsType, subField, registry, mutationQuery); } } + private Optional findMutation(String name) { + return mutations.stream() + .filter(mutation -> mutation.getName().getDisplay().equalsIgnoreCase(name)) + .findFirst(); + } + private void walkRootType(ObjectTypeDefinition rootType, TypeDefinitionRegistry registry) { checkState( !rootType.getFieldDefinitions().isEmpty(), @@ -101,20 +128,69 @@ private void walkRootType(ObjectTypeDefinition rootType, TypeDefinitionRegistry "Empty root object type: %s", rootType.getName()); for (FieldDefinition field : - rootType.getFieldDefinitions()) { // fields are root table functions + rootType.getFieldDefinitions()) { // fields are root table functions or namespaces final var fieldPath = NamePath.ROOT.concat(NamePath.of(field.getName())); - final var tableFunction = - getTableFunctionFromPath( - tableFunctions, fieldPath); // root table functions are always present + final var tableFunction = getTableFunctionFromPath(tableFunctions, fieldPath); + if (tableFunction.isPresent()) { + walkTableFunction(rootType, field, tableFunction.get(), registry); + } else if (isNamespace(field.getName())) { + walkQueryNamespace(rootType, field, registry); + } else { + checkState( + false, + field.getSourceLocation(), + "Could not find table or function for field: %s", + field.getName()); + } + } + } + + /** + * A namespace field (e.g. {@code backend: BackendQueries}) groups namespaced root functions under + * an object type. It carries no arguments; each of its sub-fields is a root table function whose + * path is {@code [namespace, subField]}. + */ + private void walkQueryNamespace( + ObjectTypeDefinition rootType, FieldDefinition nsField, TypeDefinitionRegistry registry) { + var nsType = resolveNamespaceType(nsField, registry); + visitQueryNamespace(rootType, nsField, registry); + for (FieldDefinition subField : nsType.getFieldDefinitions()) { + var subPath = NamePath.of(nsField.getName()).concat(Name.system(subField.getName())); + var tableFunction = getTableFunctionFromPath(tableFunctions, subPath); checkState( tableFunction.isPresent(), - field.getSourceLocation(), - "Could not find table or function for field: %s", - field.getName()); - walkTableFunction(rootType, field, tableFunction.get(), registry); + subField.getSourceLocation(), + "Could not find table or function for namespaced field: %s.%s", + nsField.getName(), + subField.getName()); + walkTableFunction(nsType, subField, tableFunction.get(), registry); } } + private ObjectTypeDefinition resolveNamespaceType( + FieldDefinition nsField, TypeDefinitionRegistry registry) { + // Namespace fields may carry arguments (the namespace's shared external parameters), which are + // propagated to sub-queries as parent parameters. + var typeDefOpt = registry.getType(nsField.getType()); + checkState( + typeDefOpt.isPresent(), + nsField.getType().getSourceLocation(), + "Could not find namespace object type: %s", + nsField.getType()); + var typeDefinition = typeDefOpt.get(); + checkState( + typeDefinition instanceof ObjectTypeDefinition, + typeDefinition.getSourceLocation(), + "Namespace field [%s] must reference an object type", + nsField.getName()); + return (ObjectTypeDefinition) typeDefinition; + } + + private boolean isNamespace(String name) { + return tableFunctions.stream() + .anyMatch(fn -> fn.isNamespaced() && fn.getFullPath().getFirst().getDisplay().equals(name)); + } + private void walkTableFunction( ObjectTypeDefinition parentType, FieldDefinition atField, @@ -263,7 +339,14 @@ protected abstract void visitSubscription( FieldDefinition atField, SqrlTableFunction tableFunction, TypeDefinitionRegistry registry); protected abstract void visitMutation( - FieldDefinition atField, TypeDefinitionRegistry registry, MutationTable mutation); + ObjectTypeDefinition parentType, + FieldDefinition atField, + TypeDefinitionRegistry registry, + MutationTable mutation); + + /** Visits a namespace field (e.g. {@code backend}) on a root Query or Mutation type. */ + protected abstract void visitQueryNamespace( + ObjectTypeDefinition parentType, FieldDefinition atField, TypeDefinitionRegistry registry); protected abstract void visitUnknownObject( FieldDefinition atField, Optional relDataType); diff --git a/sqrl-planner/src/main/java/com/datasqrl/server/converter/GraphQLSchemaConverter.java b/sqrl-planner/src/main/java/com/datasqrl/server/converter/GraphQLSchemaConverter.java index f507732595..8f22045eb4 100644 --- a/sqrl-planner/src/main/java/com/datasqrl/server/converter/GraphQLSchemaConverter.java +++ b/sqrl-planner/src/main/java/com/datasqrl/server/converter/GraphQLSchemaConverter.java @@ -405,6 +405,18 @@ public String convertScalarTypeToJsonType(GraphQLScalarType scalarType) { }; } + /** + * Appends a {@code ", "} variable separator to the operation header unless this is the first + * variable (i.e. the header still ends with the opening {@code (}). The header is shared across + * the recursive traversal, so sibling nested fields (e.g. under a namespace) need this shared + * check rather than a per-field counter. + */ + private static void appendHeaderSeparator(StringBuilder queryHeader) { + if (queryHeader.length() > 0 && queryHeader.charAt(queryHeader.length() - 1) != '(') { + queryHeader.append(", "); + } + } + public boolean visit( GraphQLFieldDefinition fieldDef, StringBuilder queryBody, @@ -455,7 +467,7 @@ public boolean visit( nestedField.getName(), nestedField.getDescription()); String typeString = printFieldType(nestedField); - if (numArgs > 0) queryHeader.append(", "); + appendHeaderSeparator(queryHeader); queryHeader.append(argName).append(": ").append(typeString); numArgs++; if (nestedFields.hasNext()) { @@ -476,7 +488,7 @@ public boolean visit( arg.getName(), arg.getDescription()); String typeString = printArgumentType(arg); - if (numArgs > 0) queryHeader.append(", "); + appendHeaderSeparator(queryHeader); queryHeader.append(argName).append(": ").append(typeString); numArgs++; } diff --git a/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/law_enforcement.txt b/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/law_enforcement.txt index 00950c1272..5b8117943b 100644 --- a/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/law_enforcement.txt +++ b/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/law_enforcement.txt @@ -90,7 +90,7 @@ }, "apiQuery" : { "operationType" : "QUERY", - "query" : "query Driver($license_number: String!, $limit: Int = 10, $offset: Int = 0$bolos_limit: Int = 10, $bolos_offset: Int = 0$vehicles_limit: Int = 10, $vehicles_offset: Int = 0$vehicles_bolos_limit: Int = 10, $vehicles_bolos_offset: Int = 0$vehicles_tracking_limit: Int = 10, $vehicles_tracking_offset: Int = 0$warrants_limit: Int = 10, $warrants_offset: Int = 0) {\nDriver(license_number: $license_number, limit: $limit, offset: $offset) {\ndriver_id\nfirst_name\nlast_name\nlicense_number\nlicense_state\ndate_of_birth\nlicense_status\nlicense_expiry_date\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\nvehicles(limit: $vehicles_limit, offset: $vehicles_offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $vehicles_bolos_limit, offset: $vehicles_bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $vehicles_tracking_limit, offset: $vehicles_tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\nwarrants(limit: $warrants_limit, offset: $warrants_offset) {\nwarrant_id\nperson_id\nwarrant_status\ncrime_description\nstate_of_issuance\nissue_date\nlast_updated\n}\n}\n\n}", + "query" : "query Driver($license_number: String!, $limit: Int = 10, $offset: Int = 0, $bolos_limit: Int = 10, $bolos_offset: Int = 0, $vehicles_limit: Int = 10, $vehicles_offset: Int = 0, $vehicles_bolos_limit: Int = 10, $vehicles_bolos_offset: Int = 0, $vehicles_tracking_limit: Int = 10, $vehicles_tracking_offset: Int = 0, $warrants_limit: Int = 10, $warrants_offset: Int = 0) {\nDriver(license_number: $license_number, limit: $limit, offset: $offset) {\ndriver_id\nfirst_name\nlast_name\nlicense_number\nlicense_state\ndate_of_birth\nlicense_status\nlicense_expiry_date\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\nvehicles(limit: $vehicles_limit, offset: $vehicles_offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $vehicles_bolos_limit, offset: $vehicles_bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $vehicles_tracking_limit, offset: $vehicles_tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\nwarrants(limit: $warrants_limit, offset: $warrants_offset) {\nwarrant_id\nperson_id\nwarrant_status\ncrime_description\nstate_of_issuance\nissue_date\nlast_updated\n}\n}\n\n}", "queryName" : "Driver" }, "mcpMethod" : "TOOL", @@ -134,7 +134,7 @@ }, "apiQuery" : { "operationType" : "QUERY", - "query" : "query Vehicle($registration_number: String!, $limit: Int = 10, $offset: Int = 0$bolos_limit: Int = 10, $bolos_offset: Int = 0$tracking_limit: Int = 10, $tracking_offset: Int = 0) {\nVehicle(registration_number: $registration_number, limit: $limit, offset: $offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $tracking_limit, offset: $tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\n\n}", + "query" : "query Vehicle($registration_number: String!, $limit: Int = 10, $offset: Int = 0, $bolos_limit: Int = 10, $bolos_offset: Int = 0, $tracking_limit: Int = 10, $tracking_offset: Int = 0) {\nVehicle(registration_number: $registration_number, limit: $limit, offset: $offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $tracking_limit, offset: $tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\n\n}", "queryName" : "Vehicle" }, "mcpMethod" : "TOOL", diff --git a/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/nutshop.txt b/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/nutshop.txt index d332fa40f9..81bf24277b 100644 --- a/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/nutshop.txt +++ b/sqrl-planner/src/test/resources/snapshots/com/datasqrl/converter/GraphQLSchemaConverterTest/nutshop.txt @@ -62,7 +62,7 @@ }, "apiQuery" : { "operationType" : "QUERY", - "query" : "query Products($id: Int, $limit: Int = 10, $offset: Int = 0$orders_limit: Int = 10$orders_items_limit: Int = 10) {\nProducts(id: $id, limit: $limit, offset: $offset) {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $orders_limit) {\nid\ncustomerid\ntimestamp\nitems(limit: $orders_items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\n}\ntotal {\nprice\ndiscount\n}\n}\n}\n\n}", + "query" : "query Products($id: Int, $limit: Int = 10, $offset: Int = 0, $orders_limit: Int = 10, $orders_items_limit: Int = 10) {\nProducts(id: $id, limit: $limit, offset: $offset) {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $orders_limit) {\nid\ncustomerid\ntimestamp\nitems(limit: $orders_items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\n}\ntotal {\nprice\ndiscount\n}\n}\n}\n\n}", "queryName" : "Products" }, "mcpMethod" : "TOOL", @@ -97,7 +97,7 @@ }, "apiQuery" : { "operationType" : "QUERY", - "query" : "query Orders($customerid: Int!, $limit: Int = 10, $offset: Int = 0$items_limit: Int = 10) {\nOrders(customerid: $customerid, limit: $limit, offset: $offset) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}", + "query" : "query Orders($customerid: Int!, $limit: Int = 10, $offset: Int = 0, $items_limit: Int = 10) {\nOrders(customerid: $customerid, limit: $limit, offset: $offset) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}", "queryName" : "Orders" }, "mcpMethod" : "TOOL", @@ -132,7 +132,7 @@ }, "apiQuery" : { "operationType" : "QUERY", - "query" : "query OrdersByTimeRange($customerid: Int!, $fromTime: DateTime!, $toTime: DateTime!$items_limit: Int = 10) {\nOrdersByTimeRange(customerid: $customerid, fromTime: $fromTime, toTime: $toTime) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}", + "query" : "query OrdersByTimeRange($customerid: Int!, $fromTime: DateTime!, $toTime: DateTime!, $items_limit: Int = 10) {\nOrdersByTimeRange(customerid: $customerid, fromTime: $fromTime, toTime: $toTime) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}", "queryName" : "OrdersByTimeRange" }, "mcpMethod" : "TOOL", @@ -167,7 +167,7 @@ }, "apiQuery" : { "operationType" : "QUERY", - "query" : "query OrderAgain($customerid: Int!, $limit: Int = 10, $offset: Int = 0$product_orders_limit: Int = 10) {\nOrderAgain(customerid: $customerid, limit: $limit, offset: $offset) {\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $product_orders_limit) {\nid\ncustomerid\ntimestamp\n}\n}\nnum\nquantity\n}\n\n}", + "query" : "query OrderAgain($customerid: Int!, $limit: Int = 10, $offset: Int = 0, $product_orders_limit: Int = 10) {\nOrderAgain(customerid: $customerid, limit: $limit, offset: $offset) {\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $product_orders_limit) {\nid\ncustomerid\ntimestamp\n}\n}\nnum\nquantity\n}\n\n}", "queryName" : "OrderAgain" }, "mcpMethod" : "TOOL", diff --git a/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/GraphQLEngineBuilder.java b/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/GraphQLEngineBuilder.java index cc0dd2a96c..0a9f1ebdc1 100644 --- a/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/GraphQLEngineBuilder.java +++ b/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/GraphQLEngineBuilder.java @@ -34,6 +34,7 @@ import com.datasqrl.server.graphql.RootGraphQLModel.RootVisitor; import com.datasqrl.server.graphql.RootGraphQLModel.SchemaVisitor; import com.datasqrl.server.graphql.RootGraphQLModel.SqlQuery; +import com.datasqrl.server.graphql.RootGraphQLModel.StaticQueryCoords; import com.datasqrl.server.graphql.RootGraphQLModel.StringSchema; import com.datasqrl.server.graphql.RootGraphQLModel.SubscriptionCoords; import com.datasqrl.server.jdbc.AbstractQueryExecutionContext; @@ -150,9 +151,10 @@ public GraphQL.Builder visitRoot(RootGraphQLModel root, ServerContext context) { for (MutationCoords mc : root.mutations) { DataFetcher fetcher = mc.accept(mutationConfiguration.createSinkFetcherVisitor(), context); + var parentType = + mc.getParentType() != null ? mc.getParentType() : getMutationTypeName(registry); codeRegistry.dataFetcher( - FieldCoordinates.coordinates(getMutationTypeName(registry), mc.getFieldName()), - fetcher); + FieldCoordinates.coordinates(parentType, mc.getFieldName()), fetcher); } } @@ -238,6 +240,14 @@ public DataFetcher visitFieldLookup(FieldLookupQueryCoords coords, ServerCont return context.createPropertyFetcher(coords.getColumnName()); } + @Override + public DataFetcher visitStatic(StaticQueryCoords coords, ServerContext context) { + // Return the namespace field's arguments as a non-null source object so GraphQL descends into + // the namespace's sub-fields; sub-queries bind namespace arguments (e.g. asTenantId) from this + // source via parent parameters. Empty map when the namespace has no arguments. + return env -> env.getArguments(); + } + @Override public CompletableFuture visitResolvedSqlQuery( ResolvedSqlQuery query, QueryExecutionContext context) { diff --git a/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/RootGraphQLModel.java b/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/RootGraphQLModel.java index b78cd11c8c..20b4899ea5 100644 --- a/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/RootGraphQLModel.java +++ b/sqrl-server/sqrl-server-core/src/main/java/com/datasqrl/server/graphql/RootGraphQLModel.java @@ -20,6 +20,7 @@ import com.datasqrl.server.jdbc.DatabaseType; import com.datasqrl.server.operation.ApiOperation; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; @@ -119,6 +120,8 @@ public abstract static class MutationCoords { public abstract String getFieldName(); public abstract boolean isReturnList(); + + public abstract String getParentType(); } public interface MutationCoordsVisitor { @@ -140,6 +143,13 @@ public static class KafkaMutationCoords extends MutationCoords { protected boolean transactional; protected Map sinkConfig; + /** + * The GraphQL parent type this mutation is registered under. Null for a mutation on the root + * Mutation type; set to the namespace object type name for a namespaced mutation. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + protected String parentType; + @Override public R accept(MutationCoordsVisitor visitor, C context) { return visitor.visit(this, context); @@ -192,6 +202,8 @@ public interface QueryCoordVisitor { R visitArgumentLookup(ArgumentLookupQueryCoords coords, C context); R visitFieldLookup(FieldLookupQueryCoords coords, C context); + + R visitStatic(StaticQueryCoords coords, C context); } /** @@ -206,7 +218,8 @@ public interface QueryCoordVisitor { @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(value = ArgumentLookupQueryCoords.class, name = "args"), - @Type(value = FieldLookupQueryCoords.class, name = "field") + @Type(value = FieldLookupQueryCoords.class, name = "field"), + @Type(value = StaticQueryCoords.class, name = "static") }) public abstract static class QueryCoords { @@ -234,6 +247,25 @@ public R accept(QueryCoordVisitor visitor, C context) { } } + /** + * A field that resolves to a constant empty object so that GraphQL descends into it. Used for + * namespace fields (e.g. {@code backend}) whose sub-fields carry the actual queries/mutations. + */ + @Getter + @NoArgsConstructor + public static class StaticQueryCoords extends QueryCoords { + + @Builder + public StaticQueryCoords(String parentType, String fieldName) { + super(parentType, fieldName); + } + + @Override + public R accept(QueryCoordVisitor visitor, C context) { + return visitor.visitStatic(this, context); + } + } + @Getter @NoArgsConstructor public static class ArgumentLookupQueryCoords extends QueryCoords { diff --git a/sqrl-server/sqrl-server-vertx-base/src/test/java/com/datasqrl/server/WriteIT.java b/sqrl-server/sqrl-server-vertx-base/src/test/java/com/datasqrl/server/WriteIT.java index 4db031b61e..217623d94b 100644 --- a/sqrl-server/sqrl-server-vertx-base/src/test/java/com/datasqrl/server/WriteIT.java +++ b/sqrl-server/sqrl-server-vertx-base/src/test/java/com/datasqrl/server/WriteIT.java @@ -176,7 +176,7 @@ private RootGraphQLModel getCustomerModel() { .build()) .mutation( new KafkaMutationCoords( - "addCustomer", false, topicName, List.of(), Map.of(), false, Map.of())) + "addCustomer", false, topicName, List.of(), Map.of(), false, Map.of(), null)) .build(); } diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceDuplicate-fail.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceDuplicate-fail.sqrl new file mode 100644 index 0000000000..7f976905e2 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceDuplicate-fail.sqrl @@ -0,0 +1,9 @@ +IMPORT ecommerceTs.customer; + +CREATE NAMESPACE dup ( + a BIGINT NOT NULL METADATA FROM 'auth.userid' +); + +CREATE NAMESPACE dup ( + b BIGINT NOT NULL METADATA FROM 'auth.userid' +); diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceParamMissing-fail.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceParamMissing-fail.sqrl new file mode 100644 index 0000000000..af81287b4d --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceParamMissing-fail.sqrl @@ -0,0 +1,8 @@ +IMPORT ecommerceTs.customer; + +CREATE NAMESPACE admin ( + asTenantId STRING NOT NULL +); + +-- References a parameter that the namespace does not declare. +admin.customers() := SELECT * FROM Customer WHERE CAST(:admin.nonexistent AS STRING) IS NOT NULL; diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceThisReference-fail.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceThisReference-fail.sqrl new file mode 100644 index 0000000000..51aa743723 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceThisReference-fail.sqrl @@ -0,0 +1,4 @@ +IMPORT ecommerceTs.customer; + +-- A namespaced function has no parent table, so it cannot reference `this`. +backend.bad := SELECT * FROM Customer c WHERE this.customerid = c.customerid; diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceUndeclared-fail.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceUndeclared-fail.sqrl new file mode 100644 index 0000000000..4223416a93 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespaceUndeclared-fail.sqrl @@ -0,0 +1,4 @@ +IMPORT ecommerceTs.customer; + +-- Referencing a namespace parameter without declaring the namespace with CREATE NAMESPACE. +admin.customers() := SELECT * FROM Customer WHERE CAST(:admin.asTenantId AS STRING) IS NOT NULL; diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespacedFunctionTest.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespacedFunctionTest.sqrl new file mode 100644 index 0000000000..dfd5c5a7dd --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespacedFunctionTest.sqrl @@ -0,0 +1,30 @@ +IMPORT ecommerceTs.customer; + +-- A namespace with JWT claims only: `backend` field takes no arguments. +CREATE NAMESPACE backend ( + authTenantId STRING NOT NULL METADATA FROM 'auth.https://datasqrl.com/tenant_id', + authInstallationId STRING NOT NULL METADATA FROM 'auth.https://datasqrl.com/installation_id' +); + +-- A namespace mixing an external argument (asTenantId, exposed on the `admin` field) with a claim. +CREATE NAMESPACE admin ( + asTenantId STRING NOT NULL, + role STRING NOT NULL METADATA FROM 'auth.datasqrl_admin' +); + +-- flat root query +CustomerById(customerid BIGINT NOT NULL) := SELECT * FROM Customer WHERE customerid = :customerid; + +-- relationship on Customer (still nested one hop, unaffected by namespacing) +Customer.self := SELECT * FROM Customer c WHERE this.customerid = c.customerid; + +-- namespaced queries under `backend`, inheriting the backend claims automatically +backend.customerLookup(customerid BIGINT NOT NULL) := + SELECT * FROM Customer WHERE customerid = :customerid AND CAST(:backend.authTenantId AS STRING) IS NOT NULL; +backend.tenantCustomers() := + SELECT * FROM Customer WHERE CAST(:backend.authInstallationId AS STRING) IS NOT NULL; + +-- namespaced query under `admin`, using the namespace argument and the claim +admin.customersByTenant() := + SELECT * FROM Customer + WHERE CAST(:admin.asTenantId AS STRING) IS NOT NULL AND CAST(:admin.role AS STRING) IS NOT NULL; diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespacedSubscribe-fail.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespacedSubscribe-fail.sqrl new file mode 100644 index 0000000000..d2c0bfbee2 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/dagplanner/namespacedSubscribe-fail.sqrl @@ -0,0 +1,5 @@ +IMPORT ecommerceTs.customer; + +-- Subscriptions cannot be namespaced: a GraphQL subscription operation must have a single root +-- field, so grouping subscriptions under a namespace object is not supported. +backend.updates := SUBSCRIBE SELECT * FROM Customer; diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/indexSelectionTest.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/indexSelectionTest.txt index 2819252a9d..b29cc3a808 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/indexSelectionTest.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/indexSelectionTest.txt @@ -814,7 +814,7 @@ INSERT INTO `default_catalog`.`default_database`.`Orders_2` }, "format" : "JSON", "apiQuery" : { - "query" : "query Customer($limit: Int = 10, $offset: Int = 0$orders_limit: Int = 10, $orders_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\norders(limit: $orders_limit, offset: $orders_offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\n}\n}\n\n}", + "query" : "query Customer($limit: Int = 10, $offset: Int = 0, $orders_limit: Int = 10, $orders_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\norders(limit: $orders_limit, offset: $orders_offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\n}\n}\n\n}", "queryName" : "Customer", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceDuplicate-fail.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceDuplicate-fail.txt new file mode 100644 index 0000000000..d2c632aea5 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceDuplicate-fail.txt @@ -0,0 +1,28 @@ +[FATAL] Namespace [dup] is already defined +in script:namespaceDuplicate-fail.sqrl [7:1]: +); + +CREATE NAMESPACE dup ( +^ +Invalid SQRL definition. Expected one of SELECT, DISTINCT, or column expression. + +To define a table use: +``` +Table := SELECT * FROM AnotherTable; +``` +To deduplicate a stream use: +``` +DistinctCustomers := DISTINCT Customers ON customerId ORDER BY lastUpdated DESC; +``` +To define a table function use: +``` +OlderCustomers(age INT) := SELECT * FROM Customers WHERE age >= :age; +``` +To define a relationship use: +``` +Customers.orders := SELECT * FROM Orders o WHERE o.customerid = this.id ORDER BY o.orderTime DESC; +``` +To add a column to an existing table that is defined immediately above, use: +``` +Customers.full_name := CONCAT(first_name, last_name); +``` diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceParamMissing-fail.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceParamMissing-fail.txt new file mode 100644 index 0000000000..6b1f01a8f5 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceParamMissing-fail.txt @@ -0,0 +1,15 @@ +[FATAL] Namespace [admin] has no parameter [nonexistent] +in script:namespaceParamMissing-fail.sqrl [8:57]: + +-- References a parameter that the namespace does not declare. +admin.customers() := SELECT * FROM Customer WHERE CAST(:admin.nonexistent AS STRING) IS NOT NULL; +--------------------------------------------------------^ +Invalid table function arguments provided. + +Table function arguments are defined like columns in a CREATE TABLE statement: the argument name followed by the argument type with multiple arguments separated by a comma `,`. +Inside the query body, arguments are referenced by name prefixed with a colon `:`. + +For example: +``` +MyTableFunction(argument INT, arg2 STRING NOT NULL) := SELECT * FROM MyTable WHERE col1 = :arg2 AND col2 > :argument +``` diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceThisReference-fail.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceThisReference-fail.txt new file mode 100644 index 0000000000..219ec2fb52 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceThisReference-fail.txt @@ -0,0 +1,15 @@ +[FATAL] Namespaced function [backend.bad] cannot reference `this`. It has no parent table. +in script:namespaceThisReference-fail.sqrl [4:1]: + +-- A namespaced function has no parent table, so it cannot reference `this`. +backend.bad := SELECT * FROM Customer c WHERE this.customerid = c.customerid; +^ +Invalid table function arguments provided. + +Table function arguments are defined like columns in a CREATE TABLE statement: the argument name followed by the argument type with multiple arguments separated by a comma `,`. +Inside the query body, arguments are referenced by name prefixed with a colon `:`. + +For example: +``` +MyTableFunction(argument INT, arg2 STRING NOT NULL) := SELECT * FROM MyTable WHERE col1 = :arg2 AND col2 > :argument +``` diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceUndeclared-fail.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceUndeclared-fail.txt new file mode 100644 index 0000000000..f4be4e805b --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespaceUndeclared-fail.txt @@ -0,0 +1,15 @@ +[FATAL] Namespace [admin] is not declared. Declare it with CREATE NAMESPACE. +in script:namespaceUndeclared-fail.sqrl [4:57]: + +-- Referencing a namespace parameter without declaring the namespace with CREATE NAMESPACE. +admin.customers() := SELECT * FROM Customer WHERE CAST(:admin.asTenantId AS STRING) IS NOT NULL; +--------------------------------------------------------^ +Invalid table function arguments provided. + +Table function arguments are defined like columns in a CREATE TABLE statement: the argument name followed by the argument type with multiple arguments separated by a comma `,`. +Inside the query body, arguments are referenced by name prefixed with a colon `:`. + +For example: +``` +MyTableFunction(argument INT, arg2 STRING NOT NULL) := SELECT * FROM MyTable WHERE col1 = :arg2 AND col2 > :argument +``` diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespacedFunctionTest.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespacedFunctionTest.txt new file mode 100644 index 0000000000..3e7837a253 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespacedFunctionTest.txt @@ -0,0 +1,548 @@ +>>>pipeline_explain.txt +=== Customer +ID: default_catalog.default_database.Customer +Type: stream +Stage: flink +Primary key: customerid, lastUpdated +Timestamp: timestamp +Row count: ~1e8 +--- +Schema: + - customerid: BIGINT NOT NULL + - email: VARCHAR(2147483647) CHARACTER SET "UTF-16LE" NOT NULL + - name: VARCHAR(2147483647) CHARACTER SET "UTF-16LE" NOT NULL + - lastUpdated: BIGINT NOT NULL + - timestamp: TIMESTAMP_LTZ(3) *ROWTIME* NOT NULL +Inputs: + - default_catalog.default_database.Customer__base +Annotations: + - stream-root: Customer +Plan: +LogicalWatermarkAssigner(rowtime=[timestamp], watermark=[-($4, 1:INTERVAL SECOND)]) + LogicalProject(customerid=[$0], email=[$1], name=[$2], lastUpdated=[$3], timestamp=[COALESCE(TO_TIMESTAMP_LTZ($3, 0), 1970-01-01 08:00:00:TIMESTAMP_WITH_LOCAL_TIME_ZONE(3))]) + LogicalTableScan(table=[[default_catalog, default_database, Customer]]) +SQL: +CREATE TEMPORARY TABLE `Customer__schema` ( + `customerid` BIGINT NOT NULL, + `email` VARCHAR(2147483647) CHARACTER SET `UTF-16LE` NOT NULL, + `name` VARCHAR(2147483647) CHARACTER SET `UTF-16LE` NOT NULL, + `lastUpdated` BIGINT NOT NULL +) +WITH ( + 'connector' = 'datagen' +); +CREATE TABLE `Customer` ( + `timestamp` AS COALESCE(`TO_TIMESTAMP_LTZ`(`lastUpdated`, 0), CAST(TIMESTAMP '1970-01-01 00:00:00.000' AS TIMESTAMP(3) WITH LOCAL TIME ZONE)), + PRIMARY KEY (`customerid`, `lastUpdated`) NOT ENFORCED, + WATERMARK FOR `timestamp` AS `timestamp` - INTERVAL '0.001' SECOND +) +WITH ( + 'format' = 'flexible-json', + 'path' = 'file:/mock', + 'source.monitor-interval' = '10 sec', + 'connector' = 'filesystem' +) +LIKE `Customer__schema` +=== CustomerById +ID: default_catalog.default_database.CustomerById +Type: query +Stage: postgres +--- +Inputs: + - default_catalog.default_database.Customer +Annotations: + - stream-root: Customer + - parameters: customerid + - base-table: Customer +Plan: +LogicalProject(customerid=[$0], email=[$1], name=[$2], lastUpdated=[$3], timestamp=[$4]) + LogicalFilter(condition=[=($0, ?0)]) + LogicalTableScan(table=[[default_catalog, default_database, Customer]]) +SQL: +CREATE VIEW `CustomerById` AS SELECT * FROM Customer WHERE customerid = ? ; + +>>>flink-sql-no-functions.sql +CREATE TEMPORARY TABLE `Customer__schema` ( + `customerid` BIGINT NOT NULL, + `email` VARCHAR(2147483647) CHARACTER SET `UTF-16LE` NOT NULL, + `name` VARCHAR(2147483647) CHARACTER SET `UTF-16LE` NOT NULL, + `lastUpdated` BIGINT NOT NULL +) +WITH ( + 'connector' = 'datagen' +); +CREATE TABLE `Customer` ( + `timestamp` AS COALESCE(`TO_TIMESTAMP_LTZ`(`lastUpdated`, 0), TIMESTAMP '1970-01-01 00:00:00.000'), + PRIMARY KEY (`customerid`, `lastUpdated`) NOT ENFORCED, + WATERMARK FOR `timestamp` AS `timestamp` - INTERVAL '0.001' SECOND +) +WITH ( + 'format' = 'flexible-json', + 'path' = 'file:/mock', + 'source.monitor-interval' = '10 sec', + 'connector' = 'filesystem' +) +LIKE `Customer__schema`; +CREATE TABLE `Customer_1` ( + `customerid` BIGINT NOT NULL, + `email` VARCHAR(2147483647) CHARACTER SET `UTF-16LE` NOT NULL, + `name` VARCHAR(2147483647) CHARACTER SET `UTF-16LE` NOT NULL, + `lastUpdated` BIGINT NOT NULL, + `timestamp` TIMESTAMP(3) WITH LOCAL TIME ZONE NOT NULL, + PRIMARY KEY (`customerid`, `lastUpdated`) NOT ENFORCED +) +WITH ( + 'connector' = 'jdbc-sqrl', + 'driver' = 'org.postgresql.Driver', + 'password' = '${POSTGRES_PASSWORD}', + 'sink.on-conflict.action' = 'IGNORE', + 'table-name' = 'Customer_1', + 'url' = 'jdbc:postgresql://${POSTGRES_AUTHORITY}', + 'username' = '${POSTGRES_USERNAME}' +); +EXECUTE STATEMENT SET BEGIN +INSERT INTO `default_catalog`.`default_database`.`Customer_1` +SELECT * + FROM `default_catalog`.`default_database`.`Customer` +; +END +>>>kafka.json +{ + "topics" : [ ], + "testRunnerTopics" : [ ] +} +>>>postgres.json +{ + "statements" : [ + { + "name" : "Customer_1", + "type" : "TABLE", + "sql" : "CREATE TABLE IF NOT EXISTS \"Customer_1\" (\"customerid\" BIGINT NOT NULL, \"email\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"lastUpdated\" BIGINT NOT NULL, \"timestamp\" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY (\"customerid\",\"lastUpdated\"))", + "fields" : [ + { + "name" : "customerid", + "type" : "BIGINT", + "nullable" : false + }, + { + "name" : "email", + "type" : "TEXT", + "nullable" : false + }, + { + "name" : "name", + "type" : "TEXT", + "nullable" : false + }, + { + "name" : "lastUpdated", + "type" : "BIGINT", + "nullable" : false + }, + { + "name" : "timestamp", + "type" : "TIMESTAMP WITH TIME ZONE", + "nullable" : false + } + ], + "primaryKey" : [ + "customerid", + "lastUpdated" + ], + "partitionKey" : [ ], + "partitionType" : "NONE", + "numPartitions" : 0, + "ttl" : 0.0 + }, + { + "name" : "Customer", + "type" : "VIEW", + "sql" : "CREATE OR REPLACE VIEW \"Customer\"(\"customerid\", \"email\", \"name\", \"lastUpdated\", \"timestamp\") AS SELECT *\nFROM \"Customer_1\"", + "fields" : [ + { + "name" : "customerid", + "type" : "BIGINT", + "nullable" : false + }, + { + "name" : "email", + "type" : "TEXT", + "nullable" : false + }, + { + "name" : "name", + "type" : "TEXT", + "nullable" : false + }, + { + "name" : "lastUpdated", + "type" : "BIGINT", + "nullable" : false + }, + { + "name" : "timestamp", + "type" : "TIMESTAMP WITH TIME ZONE", + "nullable" : false + } + ] + } + ], + "standaloneExtensionStatements" : [ ] +} +>>>vertx.json +{ + "models" : { + "v1" : { + "queries" : [ + { + "type" : "args", + "parentType" : "Query", + "fieldName" : "Customer", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"Customer_1\"", + "parameters" : [ ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + }, + { + "type" : "args", + "parentType" : "Customer", + "fieldName" : "self", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"Customer_1\"\nWHERE $1 = \"customerid\"", + "parameters" : [ + { + "type" : "source", + "key" : "customerid" + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + }, + { + "type" : "args", + "parentType" : "Query", + "fieldName" : "CustomerById", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "customerid" + }, + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"Customer_1\"\nWHERE \"customerid\" = $1", + "parameters" : [ + { + "type" : "arg", + "path" : "customerid", + "sqlType" : "BIGINT" + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + }, + { + "type" : "static", + "parentType" : "Query", + "fieldName" : "admin" + }, + { + "type" : "args", + "parentType" : "AdminQueries", + "fieldName" : "customersByTenant", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"Customer_1\"\nWHERE CAST($1 AS TEXT) IS NOT NULL AND CAST($2 AS TEXT) IS NOT NULL", + "parameters" : [ + { + "type" : "source", + "key" : "asTenantId" + }, + { + "type" : "metadata", + "metadata" : { + "metadataType" : "AUTH", + "name" : "datasqrl_admin", + "required" : true + } + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + }, + { + "type" : "static", + "parentType" : "Query", + "fieldName" : "backend" + }, + { + "type" : "args", + "parentType" : "BackendQueries", + "fieldName" : "customerLookup", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "customerid" + }, + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"Customer_1\"\nWHERE \"customerid\" = $1 AND CAST($2 AS TEXT) IS NOT NULL", + "parameters" : [ + { + "type" : "arg", + "path" : "customerid", + "sqlType" : "BIGINT" + }, + { + "type" : "metadata", + "metadata" : { + "metadataType" : "AUTH", + "name" : "https://datasqrl.com/tenant_id", + "required" : true + } + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + }, + { + "type" : "args", + "parentType" : "BackendQueries", + "fieldName" : "tenantCustomers", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"Customer_1\"\nWHERE CAST($1 AS TEXT) IS NOT NULL", + "parameters" : [ + { + "type" : "metadata", + "metadata" : { + "metadataType" : "AUTH", + "name" : "https://datasqrl.com/installation_id", + "required" : true + } + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + } + ], + "mutations" : [ ], + "subscriptions" : [ ], + "operations" : [ + { + "function" : { + "name" : "GetCustomer", + "parameters" : { + "type" : "object", + "properties" : { + "offset" : { + "type" : "integer" + }, + "limit" : { + "type" : "integer" + } + }, + "required" : [ ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "query Customer($limit: Int = 10, $offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\n}\n\n}", + "queryName" : "Customer", + "operationType" : "QUERY" + }, + "mcpMethod" : "TOOL", + "restMethod" : "GET", + "uriTemplate" : "queries/Customer{?offset,limit}" + }, + { + "function" : { + "name" : "GetCustomerById", + "parameters" : { + "type" : "object", + "properties" : { + "offset" : { + "type" : "integer" + }, + "customerid" : { + "type" : "integer" + }, + "limit" : { + "type" : "integer" + } + }, + "required" : [ + "customerid" + ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "query CustomerById($customerid: Long!, $limit: Int = 10, $offset: Int = 0) {\nCustomerById(customerid: $customerid, limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\n}\n\n}", + "queryName" : "CustomerById", + "operationType" : "QUERY" + }, + "mcpMethod" : "TOOL", + "restMethod" : "GET", + "uriTemplate" : "queries/CustomerById{?offset,customerid,limit}" + }, + { + "function" : { + "name" : "Getadmin", + "parameters" : { + "type" : "object", + "properties" : { + "customersByTenant_limit" : { + "type" : "integer" + }, + "asTenantId" : { + "type" : "string" + }, + "customersByTenant_offset" : { + "type" : "integer" + } + }, + "required" : [ + "asTenantId" + ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "query admin($asTenantId: String!, $customersByTenant_limit: Int = 10, $customersByTenant_offset: Int = 0) {\nadmin(asTenantId: $asTenantId) {\ncustomersByTenant(limit: $customersByTenant_limit, offset: $customersByTenant_offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\n}\n}\n\n}", + "queryName" : "admin", + "operationType" : "QUERY" + }, + "mcpMethod" : "TOOL", + "restMethod" : "GET", + "uriTemplate" : "queries/admin{?customersByTenant_limit,asTenantId,customersByTenant_offset}" + }, + { + "function" : { + "name" : "Getbackend", + "parameters" : { + "type" : "object", + "properties" : { + "customerLookup_limit" : { + "type" : "integer" + }, + "tenantCustomers_limit" : { + "type" : "integer" + }, + "tenantCustomers_offset" : { + "type" : "integer" + }, + "customerLookup_offset" : { + "type" : "integer" + }, + "customerLookup_customerid" : { + "type" : "integer" + } + }, + "required" : [ + "customerLookup_customerid" + ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "query backend($customerLookup_customerid: Long!, $customerLookup_limit: Int = 10, $customerLookup_offset: Int = 0, $tenantCustomers_limit: Int = 10, $tenantCustomers_offset: Int = 0) {\nbackend {\ncustomerLookup(customerid: $customerLookup_customerid, limit: $customerLookup_limit, offset: $customerLookup_offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\n}\ntenantCustomers(limit: $tenantCustomers_limit, offset: $tenantCustomers_offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\n}\n}\n\n}", + "queryName" : "backend", + "operationType" : "QUERY" + }, + "mcpMethod" : "TOOL", + "restMethod" : "GET", + "uriTemplate" : "queries/backend{?customerLookup_limit,tenantCustomers_limit,tenantCustomers_offset,customerLookup_offset,customerLookup_customerid}" + } + ], + "schema" : { + "type" : "string", + "schema" : "type AdminQueries {\n customersByTenant(limit: Int = 10, offset: Int = 0): [Customer!]\n}\n\ntype BackendQueries {\n customerLookup(customerid: Long!, limit: Int = 10, offset: Int = 0): [Customer!]\n tenantCustomers(limit: Int = 10, offset: Int = 0): [Customer!]\n}\n\ntype Customer {\n customerid: Long!\n email: String!\n name: String!\n lastUpdated: Long!\n timestamp: DateTime!\n self(limit: Int = 10, offset: Int = 0): [Customer!]\n}\n\n\"An RFC-3339 compliant Full Date Scalar\"\nscalar Date\n\n\"A DateTime scalar that handles both full RFC3339 and shorter timestamp formats\"\nscalar DateTime\n\n\"A JSON scalar\"\nscalar JSON\n\n\"24-hour clock time value string in the format `hh:mm:ss` or `hh:mm:ss.sss`.\"\nscalar LocalTime\n\n\"A 64-bit signed integer\"\nscalar Long\n\ntype Query {\n Customer(limit: Int = 10, offset: Int = 0): [Customer!]\n CustomerById(customerid: Long!, limit: Int = 10, offset: Int = 0): [Customer!]\n admin(asTenantId: String!): AdminQueries\n backend: BackendQueries\n}\n\nenum _McpMethodType {\n NONE\n TOOL\n RESOURCE\n}\n\nenum _RestMethodType {\n NONE\n GET\n POST\n}\n\ndirective @api(mcp: _McpMethodType, rest: _RestMethodType, uri: String) on QUERY | MUTATION | FIELD_DEFINITION\n" + } + } + } +} diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespacedSubscribe-fail.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespacedSubscribe-fail.txt new file mode 100644 index 0000000000..535b3498e9 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/namespacedSubscribe-fail.txt @@ -0,0 +1,28 @@ +[FATAL] Cannot subscribe for a relationship +in script:namespacedSubscribe-fail.sqrl [5:1]: +-- Subscriptions cannot be namespaced: a GraphQL subscription operation must have a single root +-- field, so grouping subscriptions under a namespace object is not supported. +backend.updates := SUBSCRIBE SELECT * FROM Customer; +^ +Invalid SQRL definition. Expected one of SELECT, DISTINCT, or column expression. + +To define a table use: +``` +Table := SELECT * FROM AnotherTable; +``` +To deduplicate a stream use: +``` +DistinctCustomers := DISTINCT Customers ON customerId ORDER BY lastUpdated DESC; +``` +To define a table function use: +``` +OlderCustomers(age INT) := SELECT * FROM Customers WHERE age >= :age; +``` +To define a relationship use: +``` +Customers.orders := SELECT * FROM Orders o WHERE o.customerid = this.id ORDER BY o.orderTime DESC; +``` +To add a column to an existing table that is defined immediately above, use: +``` +Customers.full_name := CONCAT(first_name, last_name); +``` diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/nestedTableWithUnnest.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/nestedTableWithUnnest.txt index 12f1d4f67c..d235bcfb35 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/nestedTableWithUnnest.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/nestedTableWithUnnest.txt @@ -325,7 +325,7 @@ INSERT INTO `default_catalog`.`default_database`.`_OrdersTotals_2` }, "format" : "JSON", "apiQuery" : { - "query" : "query Orders($limit: Int = 10, $offset: Int = 0$totals_limit: Int = 10, $totals_offset: Int = 0) {\nOrders(limit: $limit, offset: $offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\ntotals(limit: $totals_limit, offset: $totals_offset) {\nid\nprice\nsaving\n}\n}\n\n}", + "query" : "query Orders($limit: Int = 10, $offset: Int = 0, $totals_limit: Int = 10, $totals_offset: Int = 0) {\nOrders(limit: $limit, offset: $offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\ntotals(limit: $totals_limit, offset: $totals_offset) {\nid\nprice\nsaving\n}\n}\n\n}", "queryName" : "Orders", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/overview.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/overview.txt index 4d778ee67e..8e5e8f0cf7 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/overview.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/overview.txt @@ -1330,7 +1330,7 @@ INSERT INTO `default_catalog`.`default_database`.`MyPrintSink_ex1` }, "format" : "JSON", "apiQuery" : { - "query" : "query Customer($limit: Int = 10, $offset: Int = 0$largeOrders_minAmount: Float!, $largeOrders_limit: Int = 10, $largeOrders_offset: Int = 0$orders_limit: Int = 10, $orders_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\nlargeOrders(minAmount: $largeOrders_minAmount, limit: $largeOrders_limit, offset: $largeOrders_offset) {\norderid\ncustomerid\namount\norderTime\n}\norders(limit: $orders_limit, offset: $orders_offset) {\norderid\ncustomerid\namount\norderTime\n}\n}\n\n}", + "query" : "query Customer($limit: Int = 10, $offset: Int = 0, $largeOrders_minAmount: Float!, $largeOrders_limit: Int = 10, $largeOrders_offset: Int = 0, $orders_limit: Int = 10, $orders_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\nlargeOrders(minAmount: $largeOrders_minAmount, limit: $largeOrders_limit, offset: $largeOrders_offset) {\norderid\ncustomerid\namount\norderTime\n}\norders(limit: $orders_limit, offset: $orders_offset) {\norderid\ncustomerid\namount\norderTime\n}\n}\n\n}", "queryName" : "Customer", "operationType" : "QUERY" }, @@ -1507,7 +1507,7 @@ INSERT INTO `default_catalog`.`default_database`.`MyPrintSink_ex1` }, "format" : "JSON", "apiQuery" : { - "query" : "query CustomerById($customerid: Long!, $limit: Int = 10, $offset: Int = 0$largeOrders_minAmount: Float!, $largeOrders_limit: Int = 10, $largeOrders_offset: Int = 0$orders_limit: Int = 10, $orders_offset: Int = 0) {\nCustomerById(customerid: $customerid, limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\nlargeOrders(minAmount: $largeOrders_minAmount, limit: $largeOrders_limit, offset: $largeOrders_offset) {\norderid\ncustomerid\namount\norderTime\n}\norders(limit: $orders_limit, offset: $orders_offset) {\norderid\ncustomerid\namount\norderTime\n}\n}\n\n}", + "query" : "query CustomerById($customerid: Long!, $limit: Int = 10, $offset: Int = 0, $largeOrders_minAmount: Float!, $largeOrders_limit: Int = 10, $largeOrders_offset: Int = 0, $orders_limit: Int = 10, $orders_offset: Int = 0) {\nCustomerById(customerid: $customerid, limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\nlargeOrders(minAmount: $largeOrders_minAmount, limit: $largeOrders_limit, offset: $largeOrders_offset) {\norderid\ncustomerid\namount\norderTime\n}\norders(limit: $orders_limit, offset: $orders_offset) {\norderid\ncustomerid\namount\norderTime\n}\n}\n\n}", "queryName" : "CustomerById", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipInvalidArgTest.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipInvalidArgTest.txt index e8eee91991..f4e7574e53 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipInvalidArgTest.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipInvalidArgTest.txt @@ -575,7 +575,7 @@ INSERT INTO `default_catalog`.`default_database`.`Orders_2` }, "format" : "JSON", "apiQuery" : { - "query" : "query Customer($limit: Int = 10, $offset: Int = 0$orders_limit: Int = 10, $orders_offset: Int = 0$orders2_limit: Int = 10, $orders2_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\norders(limit: $orders_limit, offset: $orders_offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\n}\norders2(limit: $orders2_limit, offset: $orders2_offset) {\nid\n}\n}\n\n}", + "query" : "query Customer($limit: Int = 10, $offset: Int = 0, $orders_limit: Int = 10, $orders_offset: Int = 0, $orders2_limit: Int = 10, $orders2_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\norders(limit: $orders_limit, offset: $orders_offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\n}\norders2(limit: $orders2_limit, offset: $orders2_offset) {\nid\n}\n}\n\n}", "queryName" : "Customer", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipTest.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipTest.txt index 8cd5088dc8..31b54681cd 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipTest.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/DAGPlannerTest/relationshipTest.txt @@ -469,7 +469,7 @@ INSERT INTO `default_catalog`.`default_database`.`Orders_2` }, "format" : "JSON", "apiQuery" : { - "query" : "query Customer($limit: Int = 10, $offset: Int = 0$orders_limit: Int = 10, $orders_offset: Int = 0$orders2_limit: Int = 10, $orders2_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\norders(limit: $orders_limit, offset: $orders_offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\n}\norders2(limit: $orders2_limit, offset: $orders2_offset) {\nid\n}\n}\n\n}", + "query" : "query Customer($limit: Int = 10, $offset: Int = 0, $orders_limit: Int = 10, $orders_offset: Int = 0, $orders2_limit: Int = 10, $orders2_offset: Int = 0) {\nCustomer(limit: $limit, offset: $offset) {\ncustomerid\nemail\nname\nlastUpdated\ntimestamp\norders(limit: $orders_limit, offset: $orders_offset) {\nid\ncustomerid\ntime\nentries {\nproductid\nquantity\nunit_price\ndiscount\n}\n}\norders2(limit: $orders2_limit, offset: $orders2_offset) {\nid\n}\n}\n\n}", "queryName" : "Customer", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/banking-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/banking-package.txt index 38a3de6b44..dd700d4e8a 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/banking-package.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/banking-package.txt @@ -1385,7 +1385,7 @@ CREATE INDEX IF NOT EXISTS "Applications_hash_c1" ON "Applications" USING hash ( }, "format" : "JSON", "apiQuery" : { - "query" : "query ApplicationUpdates($limit: Int = 10, $offset: Int = 0$application_limit: Int = 10, $application_offset: Int = 0$application_loanType_limit: Int = 10, $application_loanType_offset: Int = 0) {\nApplicationUpdates(limit: $limit, offset: $offset) {\nloan_application_id\nstatus\nmessage\nevent_time\napplication(limit: $application_limit, offset: $application_offset) {\nid\ncustomer_id\nloan_type_id\namount\nduration\napplication_date\nupdated_at\nloanType(limit: $application_loanType_limit, offset: $application_loanType_offset) {\nid\nname\ndescription\ninterest_rate\nmax_amount\nmin_amount\nmax_duration\nmin_duration\nupdated_at\n}\n}\n}\n\n}", + "query" : "query ApplicationUpdates($limit: Int = 10, $offset: Int = 0, $application_limit: Int = 10, $application_offset: Int = 0, $application_loanType_limit: Int = 10, $application_loanType_offset: Int = 0) {\nApplicationUpdates(limit: $limit, offset: $offset) {\nloan_application_id\nstatus\nmessage\nevent_time\napplication(limit: $application_limit, offset: $application_offset) {\nid\ncustomer_id\nloan_type_id\namount\nduration\napplication_date\nupdated_at\nloanType(limit: $application_loanType_limit, offset: $application_loanType_offset) {\nid\nname\ndescription\ninterest_rate\nmax_amount\nmin_amount\nmax_duration\nmin_duration\nupdated_at\n}\n}\n}\n\n}", "queryName" : "ApplicationUpdates", "operationType" : "QUERY" }, @@ -1423,7 +1423,7 @@ CREATE INDEX IF NOT EXISTS "Applications_hash_c1" ON "Applications" USING hash ( }, "format" : "JSON", "apiQuery" : { - "query" : "query Applications($limit: Int = 10, $offset: Int = 0$loanType_limit: Int = 10, $loanType_offset: Int = 0$updates_limit: Int = 10, $updates_offset: Int = 0) {\nApplications(limit: $limit, offset: $offset) {\nid\ncustomer_id\nloan_type_id\namount\nduration\napplication_date\nupdated_at\nloanType(limit: $loanType_limit, offset: $loanType_offset) {\nid\nname\ndescription\ninterest_rate\nmax_amount\nmin_amount\nmax_duration\nmin_duration\nupdated_at\n}\nupdates(limit: $updates_limit, offset: $updates_offset) {\nloan_application_id\nstatus\nmessage\nevent_time\n}\n}\n\n}", + "query" : "query Applications($limit: Int = 10, $offset: Int = 0, $loanType_limit: Int = 10, $loanType_offset: Int = 0, $updates_limit: Int = 10, $updates_offset: Int = 0) {\nApplications(limit: $limit, offset: $offset) {\nid\ncustomer_id\nloan_type_id\namount\nduration\napplication_date\nupdated_at\nloanType(limit: $loanType_limit, offset: $loanType_offset) {\nid\nname\ndescription\ninterest_rate\nmax_amount\nmin_amount\nmax_duration\nmin_duration\nupdated_at\n}\nupdates(limit: $updates_limit, offset: $updates_offset) {\nloan_application_id\nstatus\nmessage\nevent_time\n}\n}\n\n}", "queryName" : "Applications", "operationType" : "QUERY" }, @@ -1525,7 +1525,7 @@ CREATE INDEX IF NOT EXISTS "Applications_hash_c1" ON "Applications" USING hash ( }, "format" : "JSON", "apiQuery" : { - "query" : "query Customers($limit: Int = 10, $offset: Int = 0$applications_limit: Int = 10, $applications_offset: Int = 0$applications_loanType_limit: Int = 10, $applications_loanType_offset: Int = 0$applications_updates_limit: Int = 10, $applications_updates_offset: Int = 0$overview_limit: Int = 10, $overview_offset: Int = 0) {\nCustomers(limit: $limit, offset: $offset) {\nid\nfirst_name\nlast_name\nemail\nphone\naddress\ndate_of_birth\nupdated_at\napplications(limit: $applications_limit, offset: $applications_offset) {\nid\ncustomer_id\nloan_type_id\namount\nduration\napplication_date\nupdated_at\nloanType(limit: $applications_loanType_limit, offset: $applications_loanType_offset) {\nid\nname\ndescription\ninterest_rate\nmax_amount\nmin_amount\nmax_duration\nmin_duration\nupdated_at\n}\nupdates(limit: $applications_updates_limit, offset: $applications_updates_offset) {\nloan_application_id\nstatus\nmessage\nevent_time\n}\n}\noverview(limit: $overview_limit, offset: $overview_offset) {\nloan_type_id\ntotal_amount\ntotal_loans\n}\n}\n\n}", + "query" : "query Customers($limit: Int = 10, $offset: Int = 0, $applications_limit: Int = 10, $applications_offset: Int = 0, $applications_loanType_limit: Int = 10, $applications_loanType_offset: Int = 0, $applications_updates_limit: Int = 10, $applications_updates_offset: Int = 0, $overview_limit: Int = 10, $overview_offset: Int = 0) {\nCustomers(limit: $limit, offset: $offset) {\nid\nfirst_name\nlast_name\nemail\nphone\naddress\ndate_of_birth\nupdated_at\napplications(limit: $applications_limit, offset: $applications_offset) {\nid\ncustomer_id\nloan_type_id\namount\nduration\napplication_date\nupdated_at\nloanType(limit: $applications_loanType_limit, offset: $applications_loanType_offset) {\nid\nname\ndescription\ninterest_rate\nmax_amount\nmin_amount\nmax_duration\nmin_duration\nupdated_at\n}\nupdates(limit: $applications_updates_limit, offset: $applications_updates_offset) {\nloan_application_id\nstatus\nmessage\nevent_time\n}\n}\noverview(limit: $overview_limit, offset: $overview_offset) {\nloan_type_id\ntotal_amount\ntotal_loans\n}\n}\n\n}", "queryName" : "Customers", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/conference-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/conference-package.txt index 7062009b2c..b095b37379 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/conference-package.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/conference-package.txt @@ -809,7 +809,7 @@ CREATE INDEX IF NOT EXISTS "Events_text_c3c4" ON "Events" USING GIN (to_tsvector }, "format" : "JSON", "apiQuery" : { - "query" : "query Events($id: Long!, $limit: Int = 10, $offset: Int = 0$likeCount_limit: Int = 10, $likeCount_offset: Int = 0) {\nEvents(id: $id, limit: $limit, offset: $offset) {\nurl\ndate\ntime\ntitle\nabstract\nlocation\nspeakers {\nname\ntitle\ncompany\n}\nlast_updated\nid\nfull_text\nstartTime\nstartTimestamp\nlikeCount(limit: $likeCount_limit, offset: $likeCount_offset) {\neventid\nnum\ntest\n}\n}\n\n}", + "query" : "query Events($id: Long!, $limit: Int = 10, $offset: Int = 0, $likeCount_limit: Int = 10, $likeCount_offset: Int = 0) {\nEvents(id: $id, limit: $limit, offset: $offset) {\nurl\ndate\ntime\ntitle\nabstract\nlocation\nspeakers {\nname\ntitle\ncompany\n}\nlast_updated\nid\nfull_text\nstartTime\nstartTimestamp\nlikeCount(limit: $likeCount_limit, offset: $likeCount_offset) {\neventid\nnum\ntest\n}\n}\n\n}", "queryName" : "Events", "operationType" : "QUERY" }, @@ -846,7 +846,7 @@ CREATE INDEX IF NOT EXISTS "Events_text_c3c4" ON "Events" USING GIN (to_tsvector }, "format" : "JSON", "apiQuery" : { - "query" : "query EventsLiked($userid: String!, $limit: Int = 10, $offset: Int = 0$likeCount_limit: Int = 10, $likeCount_offset: Int = 0) {\nEventsLiked(userid: $userid, limit: $limit, offset: $offset) {\nurl\ndate\ntime\ntitle\nabstract\nlocation\nspeakers {\nname\ntitle\ncompany\n}\nlast_updated\nid\nfull_text\nstartTime\nstartTimestamp\nlikeCount(limit: $likeCount_limit, offset: $likeCount_offset) {\neventid\nnum\ntest\n}\n}\n\n}", + "query" : "query EventsLiked($userid: String!, $limit: Int = 10, $offset: Int = 0, $likeCount_limit: Int = 10, $likeCount_offset: Int = 0) {\nEventsLiked(userid: $userid, limit: $limit, offset: $offset) {\nurl\ndate\ntime\ntitle\nabstract\nlocation\nspeakers {\nname\ntitle\ncompany\n}\nlast_updated\nid\nfull_text\nstartTime\nstartTimestamp\nlikeCount(limit: $likeCount_limit, offset: $likeCount_offset) {\neventid\nnum\ntest\n}\n}\n\n}", "queryName" : "EventsLiked", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/namespaced-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/namespaced-package.txt new file mode 100644 index 0000000000..9a63805f63 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/namespaced-package.txt @@ -0,0 +1,363 @@ +>>>pipeline_explain.txt +=== TotalUserTokens +ID: default_catalog.default_database.TotalUserTokens +Type: state +Stage: flink +Primary key: userid +Timestamp: - +Row count: ~1e7 +--- +Schema: + - userid: BIGINT NOT NULL + - total_tokens: BIGINT NOT NULL + - total_requests: BIGINT NOT NULL +Inputs: + - default_catalog.default_database.UserTokens + +=== UsageAlert +ID: default_catalog.default_database.UsageAlert +Type: stream +Stage: flink +Primary key: - +Timestamp: request_time +Row count: ~5e7 +--- +Schema: + - userid: BIGINT NOT NULL + - tokens: BIGINT NOT NULL + - request_time: TIMESTAMP_LTZ(3) *ROWTIME* NOT NULL +Inputs: + - default_catalog.default_database.UserTokens + +=== UserTokens +ID: default_catalog.default_database.UserTokens +Type: stream +Stage: flink +Primary key: - +Timestamp: request_time +Row count: ~1e8 +--- +Schema: + - userid: BIGINT NOT NULL + - tokens: BIGINT NOT NULL + - request_time: TIMESTAMP_LTZ(3) *ROWTIME* NOT NULL +Inputs: + - default_catalog.default_database.UserTokens__base + +=== UserTokensById +ID: default_catalog.default_database.UserTokensById +Type: query +Stage: postgres +--- +Inputs: + - default_catalog.default_database.TotalUserTokens +Annotations: + - parameters: userid + - base-table: TotalUserTokens + +>>>flink-sql-no-functions.sql +CREATE TABLE `UserTokens` ( + `userid` BIGINT NOT NULL, + `tokens` BIGINT NOT NULL, + `request_time` TIMESTAMP_LTZ(3) NOT NULL METADATA FROM 'timestamp', + WATERMARK FOR `request_time` AS `request_time` - INTERVAL '0.0' SECOND +) +WITH ( + 'connector' = 'kafka', + 'format' = 'flexible-json', + 'properties.auto.offset.reset' = 'earliest', + 'properties.bootstrap.servers' = '${KAFKA_BOOTSTRAP_SERVERS}', + 'properties.compression.type' = 'zstd', + 'properties.group.id' = '${KAFKA_GROUP_ID}', + 'topic' = 'UserTokens' +); +CREATE VIEW `TotalUserTokens` +AS +SELECT `userid`, SUM(`tokens`) AS `total_tokens`, COUNT(`tokens`) AS `total_requests` +FROM `UserTokens` +GROUP BY `userid`; +CREATE VIEW `UsageAlert` +AS +SELECT * +FROM `UserTokens` +WHERE `tokens` > 100; +CREATE TABLE `TotalUserTokens_1` ( + `userid` BIGINT NOT NULL, + `total_tokens` BIGINT NOT NULL, + `total_requests` BIGINT NOT NULL, + PRIMARY KEY (`userid`) NOT ENFORCED +) +WITH ( + 'connector' = 'jdbc-sqrl', + 'driver' = 'org.postgresql.Driver', + 'password' = '${POSTGRES_PASSWORD}', + 'table-name' = 'TotalUserTokens', + 'url' = 'jdbc:postgresql://${POSTGRES_AUTHORITY}', + 'username' = '${POSTGRES_USERNAME}' +); +CREATE TABLE `UsageAlert_2` ( + `userid` BIGINT NOT NULL, + `tokens` BIGINT NOT NULL, + `request_time` TIMESTAMP(3) WITH LOCAL TIME ZONE NOT NULL +) +WITH ( + 'connector' = 'kafka', + 'format' = 'flexible-json', + 'properties.bootstrap.servers' = '${KAFKA_BOOTSTRAP_SERVERS}', + 'properties.compression.type' = 'zstd', + 'properties.group.id' = '${KAFKA_GROUP_ID}', + 'topic' = 'UsageAlert' +); +EXECUTE STATEMENT SET BEGIN +INSERT INTO `default_catalog`.`default_database`.`TotalUserTokens_1` +SELECT * + FROM `default_catalog`.`default_database`.`TotalUserTokens` +; +INSERT INTO `default_catalog`.`default_database`.`UsageAlert_2` + SELECT * + FROM `default_catalog`.`default_database`.`UsageAlert` + ; + END +>>>kafka.json +{ + "topics" : [ + { + "topicName" : "UsageAlert", + "tableName" : "UsageAlert_2", + "format" : "flexible-json", + "numPartitions" : 1, + "replicationFactor" : 3, + "type" : "SUBSCRIPTION", + "messageKeys" : [ ], + "messageSchema" : "", + "config" : { } + }, + { + "topicName" : "UserTokens", + "tableName" : "UserTokens", + "format" : "flexible-json", + "numPartitions" : 1, + "replicationFactor" : 3, + "type" : "MUTATION", + "messageKeys" : [ ], + "messageSchema" : "", + "config" : { } + } + ], + "testRunnerTopics" : [ ] +} +>>>postgres-schema.sql +CREATE TABLE IF NOT EXISTS "TotalUserTokens" ("userid" BIGINT NOT NULL, "total_tokens" BIGINT NOT NULL, "total_requests" BIGINT NOT NULL, PRIMARY KEY ("userid")); + +CREATE INDEX IF NOT EXISTS "TotalUserTokens_btree_c1" ON "TotalUserTokens" USING btree ("total_tokens") +>>>postgres-views.sql + +>>>vertx.json +{ + "models" : { + "v1" : { + "queries" : [ + { + "type" : "static", + "parentType" : "Mutation", + "fieldName" : "backend" + }, + { + "type" : "args", + "parentType" : "Query", + "fieldName" : "UserTokensById", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + }, + { + "type" : "variable", + "path" : "userid" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"TotalUserTokens\"\nWHERE \"userid\" = $1", + "parameters" : [ + { + "type" : "arg", + "path" : "userid", + "sqlType" : "BIGINT" + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + }, + { + "type" : "static", + "parentType" : "Query", + "fieldName" : "backend" + }, + { + "type" : "args", + "parentType" : "BackendQueries", + "fieldName" : "tokensAbove", + "exec" : { + "arguments" : [ + { + "type" : "variable", + "path" : "limit" + }, + { + "type" : "variable", + "path" : "offset" + } + ], + "query" : { + "type" : "SqlQuery", + "sql" : "SELECT *\nFROM \"TotalUserTokens\"\nWHERE \"total_tokens\" >= $1\nORDER BY \"userid\" NULLS FIRST", + "parameters" : [ + { + "type" : "source", + "key" : "minTokens" + } + ], + "pagination" : "LIMIT_AND_OFFSET", + "cacheDurationMs" : 0, + "database" : "POSTGRES" + } + } + } + ], + "mutations" : [ + { + "type" : "kafka", + "fieldName" : "UserTokens", + "returnList" : false, + "topic" : "UserTokens", + "keyColumns" : [ ], + "computedColumns" : { + "request_time" : { + "metadataType" : "TIMESTAMP", + "name" : "", + "required" : true + } + }, + "transactional" : false, + "sinkConfig" : { }, + "parentType" : "BackendMutation" + } + ], + "subscriptions" : [ + { + "type" : "kafka", + "fieldName" : "UsageAlert", + "topic" : "UsageAlert", + "sinkConfig" : { }, + "equalityConditions" : { } + } + ], + "operations" : [ + { + "function" : { + "name" : "GetUserTokensById", + "parameters" : { + "type" : "object", + "properties" : { + "offset" : { + "type" : "integer" + }, + "limit" : { + "type" : "integer" + }, + "userid" : { + "type" : "integer" + } + }, + "required" : [ + "userid" + ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "query UserTokensById($userid: Long!, $limit: Int = 10, $offset: Int = 0) {\nUserTokensById(userid: $userid, limit: $limit, offset: $offset) {\nuserid\ntotal_tokens\ntotal_requests\n}\n\n}", + "queryName" : "UserTokensById", + "operationType" : "QUERY" + }, + "mcpMethod" : "TOOL", + "restMethod" : "GET", + "uriTemplate" : "queries/UserTokensById{?offset,limit,userid}" + }, + { + "function" : { + "name" : "Getbackend", + "parameters" : { + "type" : "object", + "properties" : { + "tokensAbove_offset" : { + "type" : "integer" + }, + "minTokens" : { + "type" : "integer" + }, + "tokensAbove_limit" : { + "type" : "integer" + } + }, + "required" : [ + "minTokens" + ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "query backend($minTokens: Long!, $tokensAbove_limit: Int = 10, $tokensAbove_offset: Int = 0) {\nbackend(minTokens: $minTokens) {\ntokensAbove(limit: $tokensAbove_limit, offset: $tokensAbove_offset) {\nuserid\ntotal_tokens\ntotal_requests\n}\n}\n\n}", + "queryName" : "backend", + "operationType" : "QUERY" + }, + "mcpMethod" : "TOOL", + "restMethod" : "GET", + "uriTemplate" : "queries/backend{?tokensAbove_offset,minTokens,tokensAbove_limit}" + }, + { + "function" : { + "name" : "Addbackend", + "parameters" : { + "type" : "object", + "properties" : { + "UserTokens_userid" : { + "type" : "integer" + }, + "UserTokens_tokens" : { + "type" : "integer" + } + }, + "required" : [ + "UserTokens_userid", + "UserTokens_tokens" + ] + } + }, + "format" : "JSON", + "apiQuery" : { + "query" : "mutation backend($UserTokens_userid: Long!, $UserTokens_tokens: Long!) {\nbackend {\nUserTokens(event: { userid: $UserTokens_userid, tokens: $UserTokens_tokens }) {\nuserid\ntokens\nrequest_time\n}\n}\n\n}", + "queryName" : "backend", + "operationType" : "MUTATION" + }, + "mcpMethod" : "TOOL", + "restMethod" : "POST", + "uriTemplate" : "mutations/backend" + } + ], + "schema" : { + "type" : "string", + "schema" : "\"An RFC-3339 compliant DateTime Scalar\"\nscalar DateTime\n\n\"A 64-bit signed integer\"\nscalar Long\n\ntype Mutation {\n backend: BackendMutation\n}\n\ntype BackendMutation {\n UserTokens(event: UserTokensInput!): UserTokensResultOutput!\n}\n\ntype Query {\n UserTokensById(userid: Long!, limit: Int = 10, offset: Int = 0): [TotalUserTokens!]\n backend(minTokens: Long!): BackendQueries\n}\n\ntype BackendQueries {\n tokensAbove(limit: Int = 10, offset: Int = 0): [TotalUserTokens!]\n}\n\ntype Subscription {\n UsageAlert: UserTokens\n}\n\ntype TotalUserTokens {\n userid: Long!\n total_tokens: Long!\n total_requests: Long!\n}\n\ntype UserTokens {\n userid: Long!\n tokens: Long!\n request_time: DateTime!\n}\n\ninput UserTokensInput {\n userid: Long!\n tokens: Long!\n}\n\ntype UserTokensResultOutput {\n userid: Long!\n tokens: Long!\n request_time: DateTime!\n}\n" + } + } + } +} diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/passthrough-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/passthrough-package.txt index b93d2d03c6..3bd6ebec04 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/passthrough-package.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/passthrough-package.txt @@ -363,7 +363,7 @@ CREATE INDEX IF NOT EXISTS "Reporting_hash_c1" ON "Reporting" USING hash ("manag }, "format" : "JSON", "apiQuery" : { - "query" : "query Employees($employeeid: Long, $name: String, $limit: Int = 10, $offset: Int = 0$allReports_limit: Int = 10, $allReports_offset: Int = 0) {\nEmployees(employeeid: $employeeid, name: $name, limit: $limit, offset: $offset) {\nemployeeid\nname\nemail\nupdatedDate\nallReports(limit: $allReports_limit, offset: $allReports_offset) {\nemployeeid\nname\nlevel\n}\n}\n\n}", + "query" : "query Employees($employeeid: Long, $name: String, $limit: Int = 10, $offset: Int = 0, $allReports_limit: Int = 10, $allReports_offset: Int = 0) {\nEmployees(employeeid: $employeeid, name: $name, limit: $limit, offset: $offset) {\nemployeeid\nname\nemail\nupdatedDate\nallReports(limit: $allReports_limit, offset: $allReports_offset) {\nemployeeid\nname\nlevel\n}\n}\n\n}", "queryName" : "Employees", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/repository-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/repository-package.txt index c5640c69a3..39edd0f8df 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/repository-package.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/repository-package.txt @@ -538,7 +538,7 @@ CREATE INDEX IF NOT EXISTS "Submission_btree_c0c3" ON "Submission" USING btree ( }, "format" : "JSON", "apiQuery" : { - "query" : "query Package($name: String!, $limit: Int = 10, $offset: Int = 0$versions_version: String!, $versions_variant: String!, $versions_limit: Int = 10, $versions_offset: Int = 0) {\nPackage(name: $name, limit: $limit, offset: $offset) {\nname\nlatest {\nname\nversion\nvariant\nlatest\ntype\nlicense\nrepository\nhomepage\ndocumentation\nreadme\ndescription\nuniqueId\nkeywords\nrepoURL\nfile\nhash\nsubmissionTime\n}\nversions(version: $versions_version, variant: $versions_variant, limit: $versions_limit, offset: $versions_offset) {\nname\nversion\nvariant\nlatest\ntype\nlicense\nrepository\nhomepage\ndocumentation\nreadme\ndescription\nuniqueId\nkeywords\nrepoURL\nfile\nhash\nsubmissionTime\n}\n}\n\n}", + "query" : "query Package($name: String!, $limit: Int = 10, $offset: Int = 0, $versions_version: String!, $versions_variant: String!, $versions_limit: Int = 10, $versions_offset: Int = 0) {\nPackage(name: $name, limit: $limit, offset: $offset) {\nname\nlatest {\nname\nversion\nvariant\nlatest\ntype\nlicense\nrepository\nhomepage\ndocumentation\nreadme\ndescription\nuniqueId\nkeywords\nrepoURL\nfile\nhash\nsubmissionTime\n}\nversions(version: $versions_version, variant: $versions_variant, limit: $versions_limit, offset: $versions_offset) {\nname\nversion\nvariant\nlatest\ntype\nlicense\nrepository\nhomepage\ndocumentation\nreadme\ndescription\nuniqueId\nkeywords\nrepoURL\nfile\nhash\nsubmissionTime\n}\n}\n\n}", "queryName" : "Package", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package-s3.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package-s3.txt index cef6cd056e..2d50a65ad0 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package-s3.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package-s3.txt @@ -1260,7 +1260,7 @@ CREATE INDEX IF NOT EXISTS "Products_text_c1c5" ON "Products" USING GIN (to_tsve }, "format" : "JSON", "apiQuery" : { - "query" : "query Customers($id: Long, $email: String, $limit: Int = 10, $offset: Int = 0$order_stats_limit: Int = 10, $order_stats_offset: Int = 0$past_purchases_limit: Int = 10, $past_purchases_offset: Int = 0$purchases_limit: Int = 10, $purchases_offset: Int = 0$purchases_totals_limit: Int = 10, $purchases_totals_offset: Int = 0$spending_limit: Int = 10, $spending_offset: Int = 0) {\nCustomers(id: $id, email: $email, limit: $limit, offset: $offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $order_stats_limit, offset: $order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $past_purchases_limit, offset: $past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\npurchases(limit: $purchases_limit, offset: $purchases_offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ntotals(limit: $purchases_totals_limit, offset: $purchases_totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\nspending(limit: $spending_limit, offset: $spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\n\n}", + "query" : "query Customers($id: Long, $email: String, $limit: Int = 10, $offset: Int = 0, $order_stats_limit: Int = 10, $order_stats_offset: Int = 0, $past_purchases_limit: Int = 10, $past_purchases_offset: Int = 0, $purchases_limit: Int = 10, $purchases_offset: Int = 0, $purchases_totals_limit: Int = 10, $purchases_totals_offset: Int = 0, $spending_limit: Int = 10, $spending_offset: Int = 0) {\nCustomers(id: $id, email: $email, limit: $limit, offset: $offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $order_stats_limit, offset: $order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $past_purchases_limit, offset: $past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\npurchases(limit: $purchases_limit, offset: $purchases_offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ntotals(limit: $purchases_totals_limit, offset: $purchases_totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\nspending(limit: $spending_limit, offset: $spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\n\n}", "queryName" : "Customers", "operationType" : "QUERY" }, @@ -1316,7 +1316,7 @@ CREATE INDEX IF NOT EXISTS "Products_text_c1c5" ON "Products" USING GIN (to_tsve }, "format" : "JSON", "apiQuery" : { - "query" : "query Orders($limit: Int = 10, $offset: Int = 0$customer_limit: Int = 10, $customer_offset: Int = 0$customer_order_stats_limit: Int = 10, $customer_order_stats_offset: Int = 0$customer_past_purchases_limit: Int = 10, $customer_past_purchases_offset: Int = 0$customer_spending_limit: Int = 10, $customer_spending_offset: Int = 0$totals_limit: Int = 10, $totals_offset: Int = 0) {\nOrders(limit: $limit, offset: $offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ncustomer(limit: $customer_limit, offset: $customer_offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $customer_order_stats_limit, offset: $customer_order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $customer_past_purchases_limit, offset: $customer_past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\nspending(limit: $customer_spending_limit, offset: $customer_spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\ntotals(limit: $totals_limit, offset: $totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\n\n}", + "query" : "query Orders($limit: Int = 10, $offset: Int = 0, $customer_limit: Int = 10, $customer_offset: Int = 0, $customer_order_stats_limit: Int = 10, $customer_order_stats_offset: Int = 0, $customer_past_purchases_limit: Int = 10, $customer_past_purchases_offset: Int = 0, $customer_spending_limit: Int = 10, $customer_spending_offset: Int = 0, $totals_limit: Int = 10, $totals_offset: Int = 0) {\nOrders(limit: $limit, offset: $offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ncustomer(limit: $customer_limit, offset: $customer_offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $customer_order_stats_limit, offset: $customer_order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $customer_past_purchases_limit, offset: $customer_past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\nspending(limit: $customer_spending_limit, offset: $customer_spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\ntotals(limit: $totals_limit, offset: $totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\n\n}", "queryName" : "Orders", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package.txt index 8016dce89a..cfa79c07b9 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/seedshop-tutorial-package.txt @@ -1260,7 +1260,7 @@ CREATE INDEX IF NOT EXISTS "Products_text_c1c5" ON "Products" USING GIN (to_tsve }, "format" : "JSON", "apiQuery" : { - "query" : "query Customers($id: Long, $email: String, $limit: Int = 10, $offset: Int = 0$order_stats_limit: Int = 10, $order_stats_offset: Int = 0$past_purchases_limit: Int = 10, $past_purchases_offset: Int = 0$purchases_limit: Int = 10, $purchases_offset: Int = 0$purchases_totals_limit: Int = 10, $purchases_totals_offset: Int = 0$spending_limit: Int = 10, $spending_offset: Int = 0) {\nCustomers(id: $id, email: $email, limit: $limit, offset: $offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $order_stats_limit, offset: $order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $past_purchases_limit, offset: $past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\npurchases(limit: $purchases_limit, offset: $purchases_offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ntotals(limit: $purchases_totals_limit, offset: $purchases_totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\nspending(limit: $spending_limit, offset: $spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\n\n}", + "query" : "query Customers($id: Long, $email: String, $limit: Int = 10, $offset: Int = 0, $order_stats_limit: Int = 10, $order_stats_offset: Int = 0, $past_purchases_limit: Int = 10, $past_purchases_offset: Int = 0, $purchases_limit: Int = 10, $purchases_offset: Int = 0, $purchases_totals_limit: Int = 10, $purchases_totals_offset: Int = 0, $spending_limit: Int = 10, $spending_offset: Int = 0) {\nCustomers(id: $id, email: $email, limit: $limit, offset: $offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $order_stats_limit, offset: $order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $past_purchases_limit, offset: $past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\npurchases(limit: $purchases_limit, offset: $purchases_offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ntotals(limit: $purchases_totals_limit, offset: $purchases_totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\nspending(limit: $spending_limit, offset: $spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\n\n}", "queryName" : "Customers", "operationType" : "QUERY" }, @@ -1316,7 +1316,7 @@ CREATE INDEX IF NOT EXISTS "Products_text_c1c5" ON "Products" USING GIN (to_tsve }, "format" : "JSON", "apiQuery" : { - "query" : "query Orders($limit: Int = 10, $offset: Int = 0$customer_limit: Int = 10, $customer_offset: Int = 0$customer_order_stats_limit: Int = 10, $customer_order_stats_offset: Int = 0$customer_past_purchases_limit: Int = 10, $customer_past_purchases_offset: Int = 0$customer_spending_limit: Int = 10, $customer_spending_offset: Int = 0$totals_limit: Int = 10, $totals_offset: Int = 0) {\nOrders(limit: $limit, offset: $offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ncustomer(limit: $customer_limit, offset: $customer_offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $customer_order_stats_limit, offset: $customer_order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $customer_past_purchases_limit, offset: $customer_past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\nspending(limit: $customer_spending_limit, offset: $customer_spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\ntotals(limit: $totals_limit, offset: $totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\n\n}", + "query" : "query Orders($limit: Int = 10, $offset: Int = 0, $customer_limit: Int = 10, $customer_offset: Int = 0, $customer_order_stats_limit: Int = 10, $customer_order_stats_offset: Int = 0, $customer_past_purchases_limit: Int = 10, $customer_past_purchases_offset: Int = 0, $customer_spending_limit: Int = 10, $customer_spending_offset: Int = 0, $totals_limit: Int = 10, $totals_offset: Int = 0) {\nOrders(limit: $limit, offset: $offset) {\nid\ncustomerid\ntime\nitems {\nproductid\nquantity\nunit_price\ndiscount\n}\ncustomer(limit: $customer_limit, offset: $customer_offset) {\nid\nfirst_name\nlast_name\nemail\nip_address\ncountry\nchanged_on\ntimestamp\norder_stats(limit: $customer_order_stats_limit, offset: $customer_order_stats_offset) {\ncustomerid\nfirst_order\ntotal_spend\ntotal_saved\nnum_orders\n}\npast_purchases(limit: $customer_past_purchases_limit, offset: $customer_past_purchases_offset) {\ncustomerid\nproductid\nnum_orders\ntotal_quantity\n}\nspending(limit: $customer_spending_limit, offset: $customer_spending_offset) {\ncustomerid\nweek\nspend\nsaved\n}\n}\ntotals(limit: $totals_limit, offset: $totals_offset) {\nid\ntime\ncustomerid\nprice\nsaving\n}\n}\n\n}", "queryName" : "Orders", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/sensors-full-compile-package.txt b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/sensors-full-compile-package.txt index 705946d8d5..c89db2eee2 100644 --- a/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/sensors-full-compile-package.txt +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/snapshots/com/datasqrl/UseCaseCompileTest/sensors-full-compile-package.txt @@ -774,7 +774,7 @@ CREATE INDEX IF NOT EXISTS "Sensors_hash_c1" ON "Sensors" USING hash ("machineId }, "format" : "JSON", "apiQuery" : { - "query" : "query Machine($limit: Int = 10, $offset: Int = 0$sensors_limit: Int = 10, $sensors_offset: Int = 0$sensors_lastHour_limit: Int = 10, $sensors_lastHour_offset: Int = 0$sensors_readings_limit: Int = 10, $sensors_readings_offset: Int = 0) {\nMachine(limit: $limit, offset: $offset) {\nmachineId\nmaxTemp\navgTemp\nsensors(limit: $sensors_limit, offset: $sensors_offset) {\nid\nmachineId\nplaced\ntimestamp\nlastHour(limit: $sensors_lastHour_limit, offset: $sensors_lastHour_offset) {\nsensorid\nwindow_time\navgTemp\nmaxTemp\n}\nreadings(limit: $sensors_readings_limit, offset: $sensors_readings_offset) {\nsensorid\ntimeSec\ntemp\n}\n}\n}\n\n}", + "query" : "query Machine($limit: Int = 10, $offset: Int = 0, $sensors_limit: Int = 10, $sensors_offset: Int = 0, $sensors_lastHour_limit: Int = 10, $sensors_lastHour_offset: Int = 0, $sensors_readings_limit: Int = 10, $sensors_readings_offset: Int = 0) {\nMachine(limit: $limit, offset: $offset) {\nmachineId\nmaxTemp\navgTemp\nsensors(limit: $sensors_limit, offset: $sensors_offset) {\nid\nmachineId\nplaced\ntimestamp\nlastHour(limit: $sensors_lastHour_limit, offset: $sensors_lastHour_offset) {\nsensorid\nwindow_time\navgTemp\nmaxTemp\n}\nreadings(limit: $sensors_readings_limit, offset: $sensors_readings_offset) {\nsensorid\ntimeSec\ntemp\n}\n}\n}\n\n}", "queryName" : "Machine", "operationType" : "QUERY" }, @@ -864,7 +864,7 @@ CREATE INDEX IF NOT EXISTS "Sensors_hash_c1" ON "Sensors" USING hash ("machineId }, "format" : "JSON", "apiQuery" : { - "query" : "query Sensors($limit: Int = 10, $offset: Int = 0$lastHour_limit: Int = 10, $lastHour_offset: Int = 0$readings_limit: Int = 10, $readings_offset: Int = 0) {\nSensors(limit: $limit, offset: $offset) {\nid\nmachineId\nplaced\ntimestamp\nlastHour(limit: $lastHour_limit, offset: $lastHour_offset) {\nsensorid\nwindow_time\navgTemp\nmaxTemp\n}\nreadings(limit: $readings_limit, offset: $readings_offset) {\nsensorid\ntimeSec\ntemp\n}\n}\n\n}", + "query" : "query Sensors($limit: Int = 10, $offset: Int = 0, $lastHour_limit: Int = 10, $lastHour_offset: Int = 0, $readings_limit: Int = 10, $readings_offset: Int = 0) {\nSensors(limit: $limit, offset: $offset) {\nid\nmachineId\nplaced\ntimestamp\nlastHour(limit: $lastHour_limit, offset: $lastHour_offset) {\nsensorid\nwindow_time\navgTemp\nmaxTemp\n}\nreadings(limit: $readings_limit, offset: $readings_offset) {\nsensorid\ntimeSec\ntemp\n}\n}\n\n}", "queryName" : "Sensors", "operationType" : "QUERY" }, diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/namespaced.sqrl b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/namespaced.sqrl new file mode 100644 index 0000000000..b9d828d326 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/namespaced.sqrl @@ -0,0 +1,25 @@ +/*+engine(kafka), no_query */ +CREATE TABLE UserTokens ( + userid BIGINT NOT NULL, + tokens BIGINT NOT NULL, + request_time TIMESTAMP_LTZ(3) NOT NULL METADATA FROM 'timestamp' +); + +TotalUserTokens := SELECT userid, sum(tokens) as total_tokens, count(tokens) as total_requests + FROM UserTokens GROUP BY userid; + +-- A namespace with an external argument shared by all its queries and exposed on the `backend` +-- field itself: backend(minTokens: Long!) { ... }. +CREATE NAMESPACE backend ( + minTokens BIGINT NOT NULL +); + +-- flat root query +UserTokensById(userid BIGINT NOT NULL) := SELECT * FROM TotalUserTokens WHERE userid = :userid; + +-- namespaced query using the namespace argument (bound from the `backend` field at runtime) +backend.tokensAbove() := + SELECT * FROM TotalUserTokens WHERE total_tokens >= :backend.minTokens ORDER BY userid ASC; + +-- subscriptions cannot be namespaced, so this stays a flat root field +UsageAlert := SUBSCRIBE SELECT * FROM UserTokens WHERE tokens > 100; diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/package.json b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/package.json new file mode 100644 index 0000000000..bfc6f196d5 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/package.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "script": { + "main": "namespaced.sqrl", + "graphql": "schema.graphqls" + }, + "test-runner": { + "use-inferred-schema": false + } +} diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/schema.graphqls b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/schema.graphqls new file mode 100644 index 0000000000..73167ee320 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/schema.graphqls @@ -0,0 +1,49 @@ +"An RFC-3339 compliant DateTime Scalar" +scalar DateTime + +"A 64-bit signed integer" +scalar Long + +type Mutation { + backend: BackendMutation +} + +type BackendMutation { + UserTokens(event: UserTokensInput!): UserTokensResultOutput! +} + +type Query { + UserTokensById(userid: Long!, limit: Int = 10, offset: Int = 0): [TotalUserTokens!] + backend(minTokens: Long!): BackendQueries +} + +type BackendQueries { + tokensAbove(limit: Int = 10, offset: Int = 0): [TotalUserTokens!] +} + +type Subscription { + UsageAlert: UserTokens +} + +type TotalUserTokens { + userid: Long! + total_tokens: Long! + total_requests: Long! +} + +type UserTokens { + userid: Long! + tokens: Long! + request_time: DateTime! +} + +input UserTokensInput { + userid: Long! + tokens: Long! +} + +type UserTokensResultOutput { + userid: Long! + tokens: Long! + request_time: DateTime! +} diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/backend-mutation.snapshot b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/backend-mutation.snapshot new file mode 100644 index 0000000000..bd2d396a3d --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/backend-mutation.snapshot @@ -0,0 +1,15 @@ +{ + "data" : { + "backend" : { + "T1" : { + "userid" : 1 + }, + "T2" : { + "userid" : 2 + }, + "T3" : { + "userid" : 1 + } + } + } +} \ No newline at end of file diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/backend-query.snapshot b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/backend-query.snapshot new file mode 100644 index 0000000000..9b339d49fb --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/backend-query.snapshot @@ -0,0 +1,11 @@ +{ + "data" : { + "backend" : { + "tokensAbove" : [ { + "userid" : 1, + "total_tokens" : 800, + "total_requests" : 2 + } ] + } + } +} \ No newline at end of file diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/usage-subscription.snapshot b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/usage-subscription.snapshot new file mode 100644 index 0000000000..66c6b7f53d --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/snapshots/usage-subscription.snapshot @@ -0,0 +1,22 @@ +[ { + "data" : { + "UsageAlert" : { + "userid" : 1, + "tokens" : 300 + } + } +}, { + "data" : { + "UsageAlert" : { + "userid" : 1, + "tokens" : 500 + } + } +}, { + "data" : { + "UsageAlert" : { + "userid" : 2, + "tokens" : 200 + } + } +} ] \ No newline at end of file diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/backend-mutation.graphql b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/backend-mutation.graphql new file mode 100644 index 0000000000..cb46f56d37 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/backend-mutation.graphql @@ -0,0 +1,13 @@ +mutation { + backend { + T1: UserTokens(event: {userid: 1, tokens: 500}) { + userid + } + T2: UserTokens(event: {userid: 2, tokens: 200}) { + userid + } + T3: UserTokens(event: {userid: 1, tokens: 300}) { + userid + } + } +} diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/backend-query.graphql b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/backend-query.graphql new file mode 100644 index 0000000000..fb4b2afbc5 --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/backend-query.graphql @@ -0,0 +1,9 @@ +query { + backend(minTokens: 300) { + tokensAbove { + userid + total_tokens + total_requests + } + } +} diff --git a/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/usage-subscription.graphql b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/usage-subscription.graphql new file mode 100644 index 0000000000..415850074d --- /dev/null +++ b/sqrl-testing/sqrl-testing-integration/src/test/resources/usecases/namespaced/tests/usage-subscription.graphql @@ -0,0 +1,6 @@ +subscription { + UsageAlert { + userid + tokens + } +}