def test_sort_order() -> None: assert sort_parser.parse("sort foo") == [Sort("foo", "asc")] assert sort_parser.parse("sort foo asc") == [Sort("foo", "asc")] parsed = sort_parser.parse("sort foo asc, bla desc, bar") assert parsed == [ Sort("foo", "asc"), Sort("bla", "desc"), Sort("bar", "asc") ] assert_round_trip(query_parser, Query.by("test").add_sort("test").add_sort("goo"))
def test_query() -> None: query = (Query.by( "ec2", P("cpu") > 4, (P("mem") < 23) | (P("mem") < 59)).merge_with( "cloud", Navigation(1, Navigation.Max, direction=Direction.inbound), Query.mk_term("cloud")).traverse_out().filter( P("some.int.value") < 1, P("some.other") == 23).traverse_out().filter( P("active") == 12, P.function("in_subnet").on( "ip", "1.2.3.4/96")).filter_with( WithClause(WithClauseFilter( "==", 0), Navigation())).group_by([ AggregateVariable( AggregateVariableName("foo")) ], [AggregateFunction("sum", "cpu")]).add_sort( Sort("test", "asc")).with_limit(10)) assert str(query) == ( 'aggregate(foo: sum(cpu)):((is("ec2") and cpu > 4) and (mem < 23 or mem < 59)) ' '{cloud: all <-default[1:]- is("cloud")} -default-> ' "(some.int.value < 1 and some.other == 23) -default-> " '(active == 12 and in_subnet(ip, "1.2.3.4/96")) ' "with(empty, -default->) sort test asc limit 10") assert_round_trip(query_parser, query)
def test_sort_order_for_synthetic_prop(foo_model: Model, graph_db: GraphDB) -> None: def check_sort_in_query(q: Query, expected_sort: str) -> None: query_str, _ = to_query(graph_db, QueryModel(q, foo_model)) assert f"SORT {expected_sort}" in query_str, f"Expected {expected_sort} in {query_str}" check_sort_in_query( Query.by("foo").add_sort(Sort("reported.age")), "m0.reported.ctime desc") check_sort_in_query( Query.by("foo").add_sort(Sort("some.age")), "m0.some.age asc") check_sort_in_query( Query.by("foo").add_sort(Sort("reported.ctime")), "m0.reported.ctime asc") check_sort_in_query( Query.by("foo").add_sort(Sort("metadata.expired")), "m0.metadata.expired asc")
def single_sort_arg_parser() -> Parser: name = yield variable_dp order = yield (space_dp >> sort_order_p).optional() return Sort(name, order if order else SortOrder.Asc)
result = self.all_parts[arg].rendered_help(ctx) elif arg and arg in self.aliases: alias = self.aliases[arg] explain = f"{arg} is an alias for {alias}\n\n" result = explain + self.all_parts[alias].rendered_help(ctx) else: result = f"No command found with this name: {arg}" return stream.just(result) return CLISource.single(help_command) CLIArg = Tuple[CLICommand, Optional[str]] # If no sort is defined in the part, we use this default sort order DefaultSort = [Sort("/reported.kind"), Sort("/reported.name"), Sort("/reported.id")] class CLI: """ The CLI has a defined set of dependencies and knows a list if commands. A string can be parsed into a command line that can be executed based on the list of available commands. """ def __init__( self, dependencies: CLIDependencies, parts: List[CLICommand], env: Dict[str, Any], aliases: Dict[str, str] ): dependencies.extend(cli=self) help_cmd = HelpCommand(dependencies, parts, aliases) cmds = {p.name: p for p in parts + [help_cmd]} alias_cmds = {alias: cmds[name] for alias, name in aliases.items() if name in cmds and alias not in cmds}
async def create_query( self, commands: List[ExecutableCommand], ctx: CLIContext ) -> Tuple[Query, Dict[str, Any], List[ExecutableCommand]]: """ Takes a list of query part commands and combine them to a single executable query command. This process can also introduce new commands that should run after the query is finished. Therefore, a list of executable commands is returned. :param commands: the incoming executable commands, which actions are all instances of SearchCLIPart. :param ctx: the context to execute within. :return: the resulting list of commands to execute. """ # Pass parsed options to execute query # Multiple query commands are possible - so the dict is combined with every parsed query. parsed_options: Dict[str, Any] = {} async def parse_query(query_arg: str) -> Query: nonlocal parsed_options parsed, query_part = ExecuteSearchCommand.parse_known(query_arg) parsed_options = {**parsed_options, **parsed} # section expansion is disabled here: it will happen on the final query after all parts have been combined return await self.dependencies.template_expander.parse_query( "".join(query_part), None, omit_section_expansion=True, **ctx.env) query: Query = Query.by(AllTerm()) additional_commands: List[ExecutableCommand] = [] # We need to remember the first head/tail, since tail will reverse the sort order first_head_tail_in_a_row: Optional[CLICommand] = None head_tail_keep_order = True for command in commands: part = command.command arg = command.arg if command.arg else "" if isinstance(part, SearchPart): query = query.combine(await parse_query(arg)) elif isinstance(part, SortPart): if query.current_part.sort == DefaultSort: query = query.set_sort(*sort_args_p.parse(arg)) else: query = query.add_sort(*sort_args_p.parse(arg)) elif isinstance(part, LimitPart): query = query.with_limit(limit_parser_direct.parse(arg)) elif isinstance(part, PredecessorsPart): origin, edge = PredecessorsPart.parse_args(arg, ctx) query = query.traverse_in(origin, 1, edge) elif isinstance(part, SuccessorsPart): origin, edge = PredecessorsPart.parse_args(arg, ctx) query = query.traverse_out(origin, 1, edge) elif isinstance(part, AncestorsPart): origin, edge = PredecessorsPart.parse_args(arg, ctx) query = query.traverse_in(origin, Navigation.Max, edge) elif isinstance(part, DescendantsPart): origin, edge = PredecessorsPart.parse_args(arg, ctx) query = query.traverse_out(origin, Navigation.Max, edge) elif isinstance(part, AggregatePart): group_vars, group_function_vars = aggregate_parameter_parser.parse( arg) query = replace(query, aggregate=Aggregate(group_vars, group_function_vars)) elif isinstance(part, CountCommand): # count command followed by a query: make it an aggregation # since the output of aggregation is not exactly the same as count # we also add the aggregate_to_count command after the query assert query.aggregate is None, "Can not combine aggregate and count!" group_by_var = [ AggregateVariable(AggregateVariableName(arg), "name") ] if arg else [] aggregate = Aggregate( group_by_var, [AggregateFunction("sum", 1, [], "count")]) # If the query should be explained, we want the output as is if "explain" not in parsed_options: additional_commands.append( self.command("aggregate_to_count", None, ctx)) query = replace(query, aggregate=aggregate) query = query.set_sort(Sort(f"{PathRoot}count")) elif isinstance(part, HeadCommand): size = HeadCommand.parse_size(arg) limit = query.parts[0].limit or Limit(0, size) if first_head_tail_in_a_row and head_tail_keep_order: query = query.with_limit( Limit(limit.offset, min(limit.length, size))) elif first_head_tail_in_a_row and not head_tail_keep_order: length = min(limit.length, size) query = query.with_limit( Limit(limit.offset + limit.length - length, length)) else: query = query.with_limit(size) elif isinstance(part, TailCommand): size = HeadCommand.parse_size(arg) limit = query.parts[0].limit or Limit(0, size) if first_head_tail_in_a_row and head_tail_keep_order: query = query.with_limit( Limit(limit.offset + max(0, limit.length - size), min(limit.length, size))) elif first_head_tail_in_a_row and not head_tail_keep_order: query = query.with_limit( Limit(limit.offset, min(limit.length, size))) else: head_tail_keep_order = False query = query.with_limit(size) p = query.current_part # the limit might have created a new part - make sure there is a sort order p = p if p.sort else replace(p, sort=DefaultSort) # reverse the sort order -> limit -> reverse the result query.parts[0] = replace( p, sort=[s.reversed() for s in p.sort], reverse_result=True) else: raise AttributeError( f"Do not understand: {part} of type: {class_fqn(part)}") # Remember the first head tail in a row of head tails if isinstance(part, (HeadCommand, TailCommand)): if not first_head_tail_in_a_row: first_head_tail_in_a_row = part else: first_head_tail_in_a_row = None head_tail_keep_order = True # Define default sort order, if not already defined # A sort order is required to always return the result in a deterministic way to the user. # Deterministic order is required for head/tail to work parts = [ pt if pt.sort else replace(pt, sort=DefaultSort) for pt in query.parts ] query = replace(query, parts=parts) # If the last part is a navigation, we need to add sort which will ingest a new part. with_sort = query.set_sort( *DefaultSort) if query.current_part.navigation else query # When all parts are combined, interpret the result on defined section. final_query = with_sort.on_section(ctx.env.get("section", PathRoot)) options = ExecuteSearchCommand.argument_string(parsed_options) query_string = str(final_query) execute_search = self.command("execute_search", options + query_string, ctx) return final_query, parsed_options, [ execute_search, *additional_commands ]
explain = f"{arg} is an alias for {alias}\n\n" result = explain + self.all_parts[alias].rendered_help(ctx) elif arg in self.alias_templates: result = self.alias_templates[arg].rendered_help(ctx) else: result = f"No command found with this name: {arg}" return stream.just(result) return CLISource.single(help_command) CLIArg = Tuple[CLICommand, Optional[str]] # If no sort is defined in the part, we use this default sort order DefaultSort = [ Sort("/reported.kind"), Sort("/reported.name"), Sort("/reported.id") ] class CIKeyDict(Dict[str, Any]): """ Special purpose dict used to lookup replacement values: - the dict should be case-insensitive: so now and NOW does not matter - if no replacement value is found, the key is returned. """ def __init__(self, **kwargs: Any) -> None: super().__init__({k.lower(): v for k, v in kwargs.items()}) def __getitem__(self, item: str) -> Any: