summaryrefslogtreecommitdiffstats
path: root/container-search/src/main/java/com/yahoo/search
diff options
context:
space:
mode:
authorJon Bratseth <bratseth@yahoo-inc.com>2016-06-15 23:09:44 +0200
committerJon Bratseth <bratseth@yahoo-inc.com>2016-06-15 23:09:44 +0200
commit72231250ed81e10d66bfe70701e64fa5fe50f712 (patch)
tree2728bba1131a6f6e5bdf95afec7d7ff9358dac50 /container-search/src/main/java/com/yahoo/search
Publish
Diffstat (limited to 'container-search/src/main/java/com/yahoo/search')
-rw-r--r--container-search/src/main/java/com/yahoo/search/Query.java1060
-rw-r--r--container-search/src/main/java/com/yahoo/search/Result.java365
-rw-r--r--container-search/src/main/java/com/yahoo/search/Searcher.java175
-rw-r--r--container-search/src/main/java/com/yahoo/search/app/.gitignore0
-rw-r--r--container-search/src/main/java/com/yahoo/search/cache/package-info.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/BaseNodeMonitor.java93
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/ClusterMonitor.java157
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/ClusterSearcher.java374
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/Hasher.java130
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/MonitorConfiguration.java140
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/NodeManager.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/PingableSearcher.java29
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/TrafficNodeMonitor.java93
-rw-r--r--container-search/src/main/java/com/yahoo/search/cluster/package-info.java12
-rw-r--r--container-search/src/main/java/com/yahoo/search/config/dispatchprototype/package-info.java9
-rw-r--r--container-search/src/main/java/com/yahoo/search/config/package-info.java5
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/BackendStatistics.java60
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/DebugMethodHandler.java13
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/DebugRpcAdaptor.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/IndentStringBuilder.java102
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/JrtMethodSignature.java17
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/OutputSearchChain.java59
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/SearchChainTextRepresentation.java88
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/SearcherUtils.java67
-rw-r--r--container-search/src/main/java/com/yahoo/search/debug/TracePackets.java44
-rw-r--r--container-search/src/main/java/com/yahoo/search/dispatch/Client.java90
-rw-r--r--container-search/src/main/java/com/yahoo/search/dispatch/Dispatcher.java228
-rw-r--r--container-search/src/main/java/com/yahoo/search/dispatch/RpcClient.java128
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/CommonFields.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/FederationSearcher.java948
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/ForwardingSearcher.java106
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/FutureWaiter.java58
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/TimeoutException.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPClientSearcher.java36
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPProviderSearcher.java68
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredSearcherHelper.java27
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/Connection.java30
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/GzipDecompressingEntity.java125
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/HTTPClientSearcher.java276
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/HTTPParameters.java315
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/HTTPProviderSearcher.java260
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/HTTPSearcher.java958
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/TimedHttpEntity.java88
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/TimedStream.java111
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/TimeoutException.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/http/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/package-info.java17
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/selection/FederationTarget.java68
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/selection/TargetSelector.java35
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/selection/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainInvocationSpec.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainResolver.java160
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/SingleTarget.java36
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/SourceRefResolver.java71
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/SourcesTarget.java112
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/Target.java31
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedProviderException.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSearchChainException.java13
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSourceRefException.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/vespa/QueryMarshaller.java170
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/vespa/ResultBuilder.java642
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/vespa/VespaSearcher.java270
-rw-r--r--container-search/src/main/java/com/yahoo/search/federation/vespa/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/Continuation.java24
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/GroupingQueryParser.java89
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/GroupingRequest.java164
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/GroupingValidator.java85
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/UnavailableAttributeException.java46
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/UniqueGroupingSearcher.java279
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AddFunction.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AggregatorNode.java53
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AllOperation.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AndFunction.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ArrayAtLookup.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AttributeFunction.java32
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AttributeValue.java32
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AvgAggregator.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/AvgFunction.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/BooleanValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/BucketResolver.java121
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/BucketValue.java54
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/CatFunction.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValue.java29
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValueComparator.java24
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/CountAggregator.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DateFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DayOfMonthFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DayOfWeekFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DayOfYearFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DebugWaitFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DivFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DocIdNsSpecificValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DocumentValue.java24
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DoubleBucket.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DoublePredefined.java48
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/DoubleValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/EachOperation.java26
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ExpressionVisitor.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/FixedWidthFunction.java34
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/FunctionNode.java78
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/GroupingExpression.java100
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/GroupingNode.java44
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/GroupingOperation.java582
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/HourOfDayFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/Infinite.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/InfiniteValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/InterpolatedLookup.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/LongBucket.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/LongPredefined.java48
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/LongValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathACosFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathACosHFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathASinFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathASinHFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathATanFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathATanHFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathCbrtFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathCosFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathCosHFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathExpFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathFloorFunction.java16
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathFunctions.java69
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathHypotFunction.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathLog10Function.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathLog1pFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathLogFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathPowFunction.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathResolver.java121
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathSinFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathSinHFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathSqrtFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathTanFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MathTanHFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MaxAggregator.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MaxFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/Md5Function.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MinAggregator.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MinFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MinuteOfHourFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ModFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MonthOfYearFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/MulFunction.java42
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/NegFunction.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/NormalizeSubjectFunction.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/NowFunction.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/OrFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/PredefinedFunction.java58
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/RawBucket.java40
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/RawBuffer.java123
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/RawPredefined.java48
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/RawValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/RelevanceValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ReverseFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/SecondOfMinuteFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/SizeFunction.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/SortFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/StrCatFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/StrLenFunction.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/StringBucket.java40
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/StringPredefined.java48
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/StringValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/SubFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/SumAggregator.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/SummaryValue.java40
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/TimeFunctions.java148
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ToDoubleFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ToLongFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ToRawFunction.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ToStringFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/UcaFunction.java63
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/XorAggregator.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/XorBitFunction.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/XorFunction.java43
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/YearFunction.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/YmumValue.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveXFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveYFunction.java18
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/request/parser/GroupingParserInput.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/AbstractList.java47
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/BucketGroupId.java60
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/DoubleBucketId.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/DoubleId.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/Group.java83
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/GroupId.java44
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/GroupList.java24
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/HitList.java25
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/HitRenderer.java99
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/LongBucketId.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/LongId.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/NullId.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/RawBucketId.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/RawId.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/RootGroup.java25
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/RootId.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/StringBucketId.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/StringId.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/ValueGroupId.java45
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/result/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/CompositeContinuation.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/ContinuationDecoder.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/EncodableContinuation.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/ExpressionConverter.java598
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingExecutor.java411
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingTransform.java137
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/HitConverter.java89
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerDecoder.java39
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerEncoder.java32
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/OffsetContinuation.java84
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/RequestBuilder.java397
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultBuilder.java353
-rw-r--r--container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultId.java71
-rw-r--r--container-search/src/main/java/com/yahoo/search/handler/HttpSearchResponse.java173
-rw-r--r--container-search/src/main/java/com/yahoo/search/handler/SearchHandler.java532
-rw-r--r--container-search/src/main/java/com/yahoo/search/handler/SearchResponse.java68
-rw-r--r--container-search/src/main/java/com/yahoo/search/handler/package-info.java11
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/Intent.java39
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/IntentModel.java90
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/IntentNode.java29
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/InterpretationNode.java45
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/Node.java48
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/ParentNode.java36
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/Source.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/SourceNode.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/intent/model/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/match/DocumentDb.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/package-info.java11
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplate.java82
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateRegistry.java16
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateSearcher.java234
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderMappingVisitor.java36
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderReferenceCreatingVisitor.java30
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/SourceVisitor.java27
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateConfigurer.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateXMLReader.java355
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/config/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Organizer.java177
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/RelevanceComparator.java29
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolution.java66
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolver.java114
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/SourceOrderComparator.java57
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/DeterministicResolver.java56
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/RandomResolver.java50
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/ResolverRegistry.java59
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/AbstractChoice.java31
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/Choice.java114
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/Layout.java50
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/MapChoice.java69
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageElement.java16
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageTemplateVisitor.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/Placeholder.java49
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/Renderer.java88
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/Section.java177
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/Source.java137
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/model/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/result/SectionHitGroup.java52
-rw-r--r--container-search/src/main/java/com/yahoo/search/pagetemplates/result/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/Model.java521
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/ParameterParser.java88
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/Presentation.java211
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/Properties.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/QueryHelper.java27
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/QueryTree.java159
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/Ranking.java246
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/SessionId.java36
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/Sorting.java407
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java112
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/context/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/package-info.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java112
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/parser/Parser.java24
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java76
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java48
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/parser/package-info.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java40
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java45
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java44
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java139
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java159
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java223
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java140
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java89
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java70
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java26
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java63
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java835
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java140
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java258
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java89
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java157
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java486
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java87
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java76
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java127
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java128
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java183
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java76
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java68
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java159
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java227
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java366
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java5
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/package-info.java12
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java148
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java94
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java86
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java100
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java355
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java59
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java11
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java40
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java58
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java132
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java296
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java67
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/properties/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java127
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java153
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java130
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java114
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java423
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java55
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java651
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java334
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java74
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java213
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java151
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java194
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java54
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java66
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java15
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java26
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java49
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java71
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java17
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java137
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java44
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java53
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java27
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java20
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore7
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java11
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java56
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java16
-rw-r--r--container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java79
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/AllLowercasingSearcher.java31
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/BooleanAttributeParser.java170
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/BooleanSearcher.java113
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/DefaultPositionSearcher.java47
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/LegacyCombinator.java365
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/LowercasingSearcher.java140
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/NGramSearcher.java285
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/QueryCombinator.java155
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/QueryTreeUtil.java35
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/RangeQueryOptimizer.java212
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/SortingDegrader.java105
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/VespaLowercasingSearcher.java39
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/WandSearcher.java206
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/package-info.java9
-rw-r--r--container-search/src/main/java/com/yahoo/search/querytransform/parser/.gitignore0
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/DefaultRenderer.java450
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/JsonRenderer.java790
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/Renderer.java96
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/RendererRegistry.java103
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/SectionedRenderer.java220
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/SyncDefaultRenderer.java471
-rw-r--r--container-search/src/main/java/com/yahoo/search/rendering/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/ChainableComparator.java34
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/Coverage.java24
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/DeepHitIterator.java85
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/DefaultErrorHit.java135
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/ErrorHit.java47
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/ErrorMessage.java210
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/FeatureData.java44
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/FieldComparator.java106
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/Hit.java787
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/HitGroup.java898
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/HitGroupsLastComparator.java34
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/HitIterator.java66
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/HitOrderer.java30
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/HitSortOrderer.java50
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/MetaHitsFirstComparator.java35
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/NanNumber.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/Relevance.java86
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/StructuredData.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/Templating.java210
-rw-r--r--container-search/src/main/java/com/yahoo/search/result/package-info.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/AsyncExecution.java204
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/Execution.java672
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/ForkingSearcher.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/FutureResult.java86
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/PhaseNames.java46
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/SearchChain.java85
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/SearchChainRegistry.java109
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/SearcherRegistry.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/example/ExampleSearcher.java23
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/VespaSearchers.java84
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationOptions.java123
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationSearcherModel.java51
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/federation/HttpProviderSpec.java121
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/federation/LocalProviderSpec.java79
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/federation/package-info.java5
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/model/package-info.java5
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/package-info.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchchain/testutil/DocumentSourceSearcher.java190
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchers/CacheControlSearcher.java75
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchers/ConnectionControlSearcher.java119
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchers/InputCheckingSearcher.java191
-rwxr-xr-xcontainer-search/src/main/java/com/yahoo/search/searchers/RateLimitingSearcher.java219
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchers/ValidateMatchPhaseSearcher.java69
-rw-r--r--container-search/src/main/java/com/yahoo/search/searchers/package-info.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/statistics/ElapsedTime.java235
-rw-r--r--container-search/src/main/java/com/yahoo/search/statistics/PeakQpsSearcher.java237
-rw-r--r--container-search/src/main/java/com/yahoo/search/statistics/TimeTracker.java390
-rw-r--r--container-search/src/main/java/com/yahoo/search/statistics/TimingSearcher.java144
-rw-r--r--container-search/src/main/java/com/yahoo/search/statistics/package-info.java7
-rw-r--r--container-search/src/main/java/com/yahoo/search/template/.gitignore0
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ArgumentsTypeChecker.java30
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveFileStream.java38
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveInputStream.java50
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ExpressionOperator.java84
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/FieldFiller.java156
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/FieldFilter.java64
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/JavaListTypeChecker.java29
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/JavaTypeChecker.java21
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/JavaUnionTypeChecker.java35
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/Location.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/MinimalQueryInserter.java98
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/NodeTypeChecker.java34
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/NullItemException.java14
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/Operator.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/OperatorNode.java261
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/OperatorNodeListTypeChecker.java35
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/OperatorTypeChecker.java19
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/OperatorVisitor.java10
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ParserBase.java38
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ProgramCompileException.java38
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ProgramParser.java1549
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ProjectOperator.java34
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/ProjectionBuilder.java73
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/SequenceOperator.java68
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/SortOperator.java33
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/StatementOperator.java41
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/StringUnescaper.java123
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/TypeCheckers.java108
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/TypeOperator.java37
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/VespaGroupingStep.java29
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/VespaSerializer.java1381
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/YqlParser.java1894
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/YqlQuery.java22
-rw-r--r--container-search/src/main/java/com/yahoo/search/yql/package-info.java11
473 files changed, 49280 insertions, 0 deletions
diff --git a/container-search/src/main/java/com/yahoo/search/Query.java b/container-search/src/main/java/com/yahoo/search/Query.java
new file mode 100644
index 00000000000..20831e743b9
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/Query.java
@@ -0,0 +1,1060 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.collections.Tuple2;
+import com.yahoo.component.Version;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.fs4.MapEncoder;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.fastsearch.DocumentDatabase;
+import com.yahoo.prelude.query.Highlight;
+import com.yahoo.prelude.query.QueryException;
+import com.yahoo.prelude.query.textualrepresentation.TextualQueryRepresentation;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.types.FieldType;
+import com.yahoo.search.query.properties.PropertyMap;
+import com.yahoo.search.query.Model;
+import com.yahoo.search.query.ParameterParser;
+import com.yahoo.search.query.Presentation;
+import com.yahoo.search.query.QueryTree;
+import com.yahoo.search.query.Ranking;
+import com.yahoo.search.query.SessionId;
+import com.yahoo.search.query.Sorting;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileFieldType;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry;
+import com.yahoo.search.query.properties.DefaultProperties;
+import com.yahoo.search.query.properties.QueryProperties;
+import com.yahoo.search.query.properties.QueryPropertyAliases;
+import com.yahoo.search.query.properties.RequestContextProperties;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.federation.FederationSearcher;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.query.Sorting.AttributeSorter;
+import com.yahoo.search.query.Sorting.FieldOrder;
+import com.yahoo.search.query.Sorting.Order;
+import com.yahoo.search.query.context.QueryContext;
+import com.yahoo.search.query.profile.ModelObjectMap;
+import com.yahoo.search.query.profile.QueryProfileProperties;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfile;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.yql.NullItemException;
+import com.yahoo.search.yql.VespaSerializer;
+import com.yahoo.search.yql.YqlParser;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+/**
+ * A search query containing all the information required to produce a Result.
+ * <p>
+ * The Query contains:
+ * <ul>
+ * <li>the selection criterion received in the request - which may be a structured boolean tree of operators,
+ * an annotated piece of natural language text received from a user, or a combination of both
+ * <li>a set of field containing the additional general parameters of a query - number of hits,
+ * ranking, presentation etc.
+ * <li>a Map of properties, which can be of any object type
+ * </ul>
+ *
+ * <p>
+ * The properties has three sources
+ * <ol>
+ * <li>They may be set in some Searcher component already executed for this Query - the properties acts as
+ * a blackboard for communicating arbitrary objects between Searcher components.
+ * <li>Properties set in the search Request received - the properties acts as a way to parametrize Searcher
+ * components from the Request.
+ * <li>Properties defined in the selected {@link com.yahoo.search.query.profile.QueryProfile} - this provides
+ * defaults for the parameters to Searcher components. Note that by using query profile types, the components may
+ * define the set of parameters they support.
+ * </ol>
+ * When looked up, the properties are accessed in the priority order listed above.
+ * <p>
+ * The identity of a query is determined by its content.
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author bratseth
+ */
+public class Query extends com.yahoo.processing.Request implements Cloneable {
+
+ // Note to developers: If you think you should add something here you are probably wrong
+ // To add state to the query: Do properties.set("myNewState",new MyNewState()) instead.
+
+ /** The type of the query */
+ public enum Type {
+
+ ALL(0,"all"),
+ ANY(1,"any"),
+ PHRASE(2,"phrase"),
+ ADVANCED(3,"adv"),
+ WEB(4,"web"),
+ PROGRAMMATIC(5, "prog"),
+ YQL(6, "yql");
+
+ private final int intValue;
+ private final String stringValue;
+
+ Type(int intValue,String stringValue) {
+ this.intValue = intValue;
+ this.stringValue = stringValue;
+ }
+
+ /** Converts a type argument value into a query type */
+ public static Type getType(String typeString) {
+ for (Type type:Type.values())
+ if(type.stringValue.equals(typeString))
+ return type;
+ return ALL;
+ }
+
+ public int asInt() { return intValue; }
+
+ public String toString() { return stringValue; }
+
+ }
+
+ //-------------- Query properties treated as fields in Query ---------------
+
+ /** The offset from the most relevant hits found from this query */
+ private int offset = 0;
+
+ /** The number of hits to return */
+ private int hits = 10;
+
+ /** The query context level, 0 means no tracing */
+ private int traceLevel = 0;
+
+ // The timeout to be used when dumping rank features
+ private static final long dumpTimeout = (6 * 60 * 1000); // 6 minutes
+ private static final long defaultTimeout = 5000;
+ /** The timeout of the query, in milliseconds */
+ private long timeout = defaultTimeout;
+
+
+ /** Whether this query is forbidden to access cached information */
+ private boolean noCache=false;
+
+ /** Whether or not grouping should use a session cache */
+ private boolean groupingSessionCache=false;
+
+ //-------------- Generic property containers --------------------------------
+
+ /**
+ * The synchronous view of the JDisc request causing this query.
+ *
+ * @since 5.1
+ */
+ private final HttpRequest httpRequest;
+
+ /** The context, or null if there is no context */
+ private QueryContext context = null;
+
+ /** Used for downstream session caches */
+ private SessionId sessionId = null;
+
+ //--------------- Owned sub-objects containing query properties ----------------
+
+ /** The ranking requested in this query */
+ private Ranking ranking = new Ranking(this);
+
+ /** The query query and/or query program declaration */
+ private Model model = new Model(this);
+
+ /** How results of this query should be presented */
+ private Presentation presentation = new Presentation(this);
+
+ //---------------- Tracing ----------------------------------------------------
+
+ private static Logger log = Logger.getLogger(Query.class.getName());
+
+ /** The time this query was created */
+ private long startTime;
+
+ /** Error conditions stemming from the query itself */
+ private List<ErrorMessage> errors = new ArrayList<>(0);
+
+ //---------------- Static property handling ------------------------------------
+
+ public static final CompoundName OFFSET = new CompoundName("offset");
+ public static final CompoundName HITS = new CompoundName("hits");
+
+ public static final CompoundName SEARCH_CHAIN = new CompoundName("searchChain");
+ public static final CompoundName TRACE_LEVEL = new CompoundName("traceLevel");
+ public static final CompoundName NO_CACHE = new CompoundName("noCache");
+ public static final CompoundName GROUPING_SESSION_CACHE = new CompoundName("groupingSessionCache");
+ public static final CompoundName TIMEOUT = new CompoundName("timeout");
+
+ private static QueryProfileType argumentType;
+ static {
+ argumentType=new QueryProfileType("native");
+ argumentType.setBuiltin(true);
+
+ argumentType.addField(new FieldDescription(OFFSET.toString(), "integer", "offset start"));
+ argumentType.addField(new FieldDescription(HITS.toString(), "integer", "hits count"));
+ // TODO: Should this be added to com.yahoo.search.query.properties.QueryProperties? If not, why not?
+ argumentType.addField(new FieldDescription(SEARCH_CHAIN.toString(), "string"));
+ argumentType.addField(new FieldDescription(TRACE_LEVEL.toString(), "integer", "tracelevel"));
+ argumentType.addField(new FieldDescription(NO_CACHE.toString(), "boolean", "nocache"));
+ argumentType.addField(new FieldDescription(GROUPING_SESSION_CACHE.toString(), "boolean", "groupingSessionCache"));
+ argumentType.addField(new FieldDescription(TIMEOUT.toString(), "string", "timeout"));
+ argumentType.addField(new FieldDescription(FederationSearcher.SOURCENAME.toString(),"string"));
+ argumentType.addField(new FieldDescription(FederationSearcher.PROVIDERNAME.toString(),"string"));
+ argumentType.addField(new FieldDescription(Presentation.PRESENTATION,new QueryProfileFieldType(Presentation.getArgumentType())));
+ argumentType.addField(new FieldDescription(Ranking.RANKING,new QueryProfileFieldType(Ranking.getArgumentType())));
+ argumentType.addField(new FieldDescription(Model.MODEL,new QueryProfileFieldType(Model.getArgumentType())));
+ argumentType.freeze();
+ }
+ public static QueryProfileType getArgumentType() { return argumentType; }
+
+ /** The aliases of query properties, these are always the same */
+ // Note: Don't make static for now as GSM calls this through reflection
+ private static Map<String,CompoundName> propertyAliases;
+ static {
+ Map<String,CompoundName> propertyAliasesBuilder = new HashMap<>();
+ addAliases(Query.getArgumentType(), propertyAliasesBuilder);
+ addAliases(Ranking.getArgumentType(), propertyAliasesBuilder);
+ addAliases(Model.getArgumentType(), propertyAliasesBuilder);
+ addAliases(Presentation.getArgumentType(), propertyAliasesBuilder);
+ propertyAliases = ImmutableMap.copyOf(propertyAliasesBuilder);
+ }
+ private static void addAliases(QueryProfileType arguments,Map<String,CompoundName> aliases) {
+ String prefix=getPrefix(arguments);
+ for (FieldDescription field : arguments.fields().values()) {
+ for (String alias : field.getAliases())
+ aliases.put(alias,new CompoundName(prefix+field.getName()));
+ }
+ }
+ private static String getPrefix(QueryProfileType type) {
+ if (type.getId().getName().equals("native")) return ""; // The arguments of this directly
+ return type.getId().getName() + ".";
+ }
+
+ public static void addNativeQueryProfileTypesTo(QueryProfileTypeRegistry registry) {
+ // Add modifiable copies to allow query profile types in this to add to these
+ registry.register(Query.getArgumentType().unfrozen());
+ registry.register(Ranking.getArgumentType().unfrozen());
+ registry.register(Model.getArgumentType().unfrozen());
+ registry.register(Presentation.getArgumentType().unfrozen());
+ registry.register(DefaultProperties.argumentType.unfrozen());
+ }
+
+ //---------------- Construction ------------------------------------
+
+ /**
+ * Constructs an empty (null) query
+ */
+ public Query() {
+ this("");
+ }
+
+ /**
+ * Construct a query from a string formatted in the http style, e.g <code>?query=test&amp;offset=10&amp;hits=13</code>
+ * The query must be uri encoded.
+ */
+ public Query(String query) {
+ this(query, null);
+ }
+
+ /**
+ * Construct a query from a string formatted in the http style, e.g <code>?query=test&amp;offset=10&amp;hits=13</code>
+ * The query must be uri encoded.
+ */
+ public Query(String query, CompiledQueryProfile queryProfile) {
+ this(HttpRequest.createTestRequest(query, com.yahoo.jdisc.http.HttpRequest.Method.GET), queryProfile);
+ }
+
+ /**
+ * Creates a query from a request
+ *
+ * @param request the HTTP request from which this is created
+ * @param queryProfile the query profile to use for this query, or null if none.
+ */
+ public Query(HttpRequest request, CompiledQueryProfile queryProfile) {
+ super(new QueryPropertyAliases(propertyAliases));
+ this.httpRequest = request;
+ init(request.propertyMap(), queryProfile);
+ }
+
+ /**
+ * Creates a query from a request
+ *
+ * @param request the HTTP request from which this is created
+ */
+ public Query(HttpRequest request) {
+ this(request, null);
+ }
+
+ private void init(Map<String, String> requestMap, CompiledQueryProfile queryProfile) {
+ startTime = System.currentTimeMillis();
+ if (queryProfile != null) {
+ // Move all request parameters to the query profile just to validate that the parameter settings are legal
+ Properties queryProfileProperties=new QueryProfileProperties(queryProfile);
+ properties().chain(queryProfileProperties);
+ // TODO: Just checking legality rather than actually setting would be faster
+ setPropertiesFromRequestMap(requestMap, properties()); // Adds errors to the query for illegal set attempts
+
+ // Create the full chain
+ properties().chain(new QueryProperties(this, queryProfile.getRegistry())).
+ chain(new ModelObjectMap()).
+ chain(new RequestContextProperties(requestMap)).
+ chain(queryProfileProperties).
+ chain(new DefaultProperties());
+
+ // Pass the values from the query profile which maps through a field in the Query object model
+ // through the property chain to cause those values to be set in the Query object model
+ setFieldsFrom(queryProfileProperties, requestMap);
+ }
+ else { // bypass these complications if there is no query profile to get values from and validate against
+ properties().
+ chain(new QueryProperties(this, new CompiledQueryProfileRegistry())).
+ chain(new PropertyMap()).
+ chain(new DefaultProperties());
+ setPropertiesFromRequestMap(requestMap, properties());
+ }
+
+ properties().setParentQuery(this);
+ traceProperties();
+ }
+
+ public Query(Query query) {
+ this(query, query.getStartTime());
+ }
+
+ private Query(Query query, long startTime) {
+ super(query.properties().clone());
+ this.startTime = startTime;
+ this.httpRequest = query.httpRequest;
+ query.copyPropertiesTo(this);
+ }
+
+ /**
+ * Creates a new query from another query, but with time sensitive
+ * fields reset.
+ *
+ * @return new query
+ */
+ public static Query createNewQuery(Query query) {
+ return new Query(query, System.currentTimeMillis());
+ }
+
+ /**
+ * Calls properties().set on each value in the given properties which is declared in this query or
+ * one of its dependent objects. This will ensure the appropriate setters are called on this and all
+ * dependent objects for the appropriate subset of the given property values
+ */
+ private void setFieldsFrom(Properties properties, Map<String,String> context) {
+ setFrom(properties,Query.getArgumentType(), context);
+ setFrom(properties,Model.getArgumentType(), context);
+ setFrom(properties,Presentation.getArgumentType(), context);
+ setFrom(properties,Ranking.getArgumentType(), context);
+ }
+
+ /**
+ * For each field in the given query profile type, take the corresponding value from originalProperties
+ * (if any) set it to properties().
+ */
+ private void setFrom(Properties originalProperties,QueryProfileType arguments,Map<String,String> context) {
+ String prefix=getPrefix(arguments);
+ for (FieldDescription field : arguments.fields().values()) {
+ String fullName=prefix + field.getName();
+ if (field.getType() == FieldType.genericQueryProfileType) {
+ for (Map.Entry<String, Object> entry : originalProperties.listProperties(fullName,context).entrySet()) {
+ try {
+ properties().set(fullName + "." + entry.getKey(), entry.getValue(), context);
+ } catch (IllegalArgumentException e) {
+ throw new QueryException("Invalid request parameter", e);
+ }
+ }
+ } else {
+ Object value=originalProperties.get(fullName,context);
+ if (value!=null) {
+ try {
+ properties().set(fullName,value,context);
+ } catch (IllegalArgumentException e) {
+ throw new QueryException("Invalid request parameter", e);
+ }
+ }
+ }
+ }
+ }
+
+ /** Calls properties.set on all entries in requestMap */
+ private void setPropertiesFromRequestMap(Map<String, String> requestMap, Properties properties) {
+ for (Map.Entry<String, String> entry : requestMap.entrySet()) {
+ try {
+ if (entry.getKey().equals("queryProfile")) continue;
+ properties.set(entry.getKey(), entry.getValue(), requestMap);
+ }
+ catch (IllegalArgumentException e) {
+ throw new QueryException("Invalid request parameter", e);
+ }
+ }
+ }
+
+ /** Returns the properties of this query. The properties are modifiable */
+ @Override
+ public Properties properties() { return (Properties)super.properties(); }
+
+ /**
+ * Traces how properties was resolved and from where. Done after the fact to avoid special handling
+ * of tracelevel, which is the property deciding whether this needs to be done
+ */
+ private void traceProperties() {
+ if (traceLevel==0) return;
+ CompiledQueryProfile profile=null;
+ QueryProfileProperties profileProperties=properties().getInstance(QueryProfileProperties.class);
+ if (profileProperties!=null)
+ profile=profileProperties.getQueryProfile();
+
+ if (profile==null)
+ trace("No query profile is used", false, 1);
+ else
+ trace("Using " + profile.toString(), false, 1);
+ if (traceLevel<4) return;
+
+ StringBuilder b=new StringBuilder("Resolved properties:\n");
+ Set<String> mentioned=new HashSet<>();
+ for (Map.Entry<String,String> requestProperty : requestProperties().entrySet() ) {
+ Object resolvedValue = properties().get(requestProperty.getKey(), requestProperties());
+ if (resolvedValue == null && requestProperty.getKey().equals("queryProfile"))
+ resolvedValue = requestProperty.getValue();
+
+ b.append(requestProperty.getKey());
+ b.append("=");
+ b.append(String.valueOf(resolvedValue)); // (may be null)
+ b.append(" (");
+
+ if (profile != null && ! profile.isOverridable(new CompoundName(requestProperty.getKey()), requestProperties()))
+ b.append("value from query profile - unoverridable, ignoring request value");
+ else
+ b.append("value from request");
+ b.append(")\n");
+ mentioned.add(requestProperty.getKey());
+ }
+ if (profile!=null) {
+ appendQueryProfileProperties(profile,mentioned,b);
+ }
+ trace(b.toString(),false,4);
+ }
+
+ private Map<String, String> requestProperties() {
+ return httpRequest.propertyMap();
+ }
+
+ private void appendQueryProfileProperties(CompiledQueryProfile profile,Set<String> mentioned,StringBuilder b) {
+ for (Map.Entry<String,Object> property : profile.listValues("",requestProperties()).entrySet()) {
+ if ( ! mentioned.contains(property.getKey()))
+ b.append(property.getKey() + "=" + property.getValue() + " (value from query profile)<br/>\n");
+ }
+ }
+
+ /**
+ * Validates this query
+ *
+ * @return the reason if it is invalid, null if it is valid
+ */
+ public String validate() {
+ // Validate the query profile
+ QueryProfileProperties queryProfileProperties = properties().getInstance(QueryProfileProperties.class);
+ if (queryProfileProperties == null) return null; // Valid
+ StringBuilder missingName = new StringBuilder();
+ if (! queryProfileProperties.isComplete(missingName, httpRequest.propertyMap()))
+ return "Incomplete query: Parameter '" + missingName + "' is mandatory in " +
+ queryProfileProperties.getQueryProfile() + " but is not set";
+ else
+ return null; // is valid
+ }
+
+ /** Returns the time (in milliseconds since epoch) when this query was started */
+ public long getStartTime() { return startTime; }
+
+ /** Returns the time (in milliseconds) since the query was started/created */
+ public long getDurationTime() {
+ return System.currentTimeMillis() - startTime;
+ }
+
+ /**
+ * Get the appropriate timeout for the query.
+ *
+ * @return timeout in milliseconds
+ **/
+ public long getTimeLeft() {
+ return getTimeout() - getDurationTime();
+ }
+
+ public boolean requestHasProperty(String name) {
+ return httpRequest.hasProperty(name);
+ }
+
+ /**
+ * Returns the number of milliseconds to wait for a response from a search backend
+ * before timing it out. Default is 5000.
+ * <p>
+ * Note: If Ranking.RANKFEATURES is turned on, this is hardcoded to 6 minutes.
+ *
+ * @return timeout in milliseconds.
+ */
+ public long getTimeout() {
+ return properties().getBoolean(Ranking.RANKFEATURES, false) ? dumpTimeout : timeout;
+ }
+
+ /**
+ * Sets the number of milliseconds to wait for a response from a search backend
+ * before time out. Default is 5000.
+ */
+ public void setTimeout(long timeout) {
+ if (timeout > 1000000000 || timeout < 0)
+ throw new IllegalArgumentException("'timeout' must be positive and smaller than 1000000000 ms but was " + timeout);
+ this.timeout = timeout;
+ }
+
+ /**
+ * Sets timeout from a string which will be parsed as a
+ */
+ public void setTimeout(String timeoutString) {
+ setTimeout(ParameterParser.asMilliSeconds(timeoutString, timeout));
+ }
+
+ /**
+ * Resets the start time of the query. This will ensure that the query will run
+ * for the same amount of time as a newly created query.
+ */
+ public void resetTimeout() { this.startTime = System.currentTimeMillis(); }
+
+ /**
+ * Sets the context level of this query, 0 means no tracing
+ * Higher numbers means increasingly more tracing
+ */
+ public void setTraceLevel(int traceLevel) { this.traceLevel = traceLevel; }
+
+ /**
+ * Returns the context level of this query, 0 means no tracing
+ * Higher numbers means increasingly more tracing
+ */
+ public int getTraceLevel() { return traceLevel; }
+
+ /**
+ * Returns the context level of this query, 0 means no tracing
+ * Higher numbers means increasingly more tracing
+ */
+ public final boolean isTraceable(int level) { return traceLevel >= level; }
+
+
+ /** Returns whether this query should never be served from a cache. Default is false */
+ public boolean getNoCache() { return noCache; }
+
+ /** Sets whether this query should never be server from a cache. Default is false */
+ public void setNoCache(boolean noCache) { this.noCache = noCache; }
+
+ /** Returns whether this query should use the grouping session cache. Default is false */
+ public boolean getGroupingSessionCache() { return groupingSessionCache; }
+
+ /** Sets whether this query should use the grouping session cache. Default is false */
+ public void setGroupingSessionCache(boolean groupingSessionCache) { this.groupingSessionCache = groupingSessionCache; }
+
+ /**
+ * Returns the offset from the most relevant hits requested by the submitter
+ * of this query.
+ * Default is 0 - to return the most relevant hits
+ */
+ public int getOffset() { return offset; }
+
+ /**
+ * Returns the number of hits requested by the submitter of this query.
+ * The default is 10.
+ */
+ public int getHits() { return hits; }
+
+ /**
+ * Sets the number of hits requested. If hits is less than 0, an
+ * IllegalArgumentException is thrown. Default number of hits is 10.
+ */
+ public void setHits(int hits) {
+ if (hits < 0)
+ throw new IllegalArgumentException("Must be a positive number");
+ this.hits = hits;
+ }
+
+ /**
+ * Set the hit offset. Can not be less than 0. Default is 0.
+ */
+ public void setOffset(int offset) {
+ if (offset < 0)
+ throw new IllegalArgumentException("Must be a positive number");
+ this.offset = offset;
+ }
+
+ /** Convenience method to set both the offset and the number of hits to return */
+ public void setWindow(int offset,int hits) {
+ setOffset(offset);
+ setHits(hits);
+ }
+
+ /**
+ * This is ignored - compression is controlled at the network level.
+ *
+ * @deprecated this is ignored
+ */
+ @Deprecated
+ public void setCompress(boolean ignored) { }
+
+ /**
+ * Returns false.
+ *
+ * @deprecated this always returns false
+ */
+ @Deprecated
+ public boolean getCompress() { return false; }
+
+ /** Returns a string describing this query */
+ @Override
+ public String toString() {
+ String queryTree;
+ // getQueryTree isn't exception safe
+ try {
+ queryTree = model.getQueryTree().toString();
+ } catch (Exception e) {
+ queryTree = "[Could not parse user input: " + model.getQueryString() + "]";
+ }
+ return "query '" + queryTree + "'";
+ }
+
+ /** Returns a string describing this query in more detail */
+ public String toDetailString() {
+ String queryTree;
+ // getQueryTree isn't exception safe
+ try {
+ queryTree = model.getQueryTree().toString();
+ } catch (Exception e) {
+ queryTree = "Could not parse user input: " + model.getQueryString();
+ }
+ return "query=[" + queryTree + "]" + " offset=" + getOffset() + " hits=" + getHits() + "]";
+ }
+
+ /**
+ * Encodes this query onto the given buffer
+ *
+ * @param buffer The buffer to encode the query to
+ * @return the number of encoded items
+ */
+ public int encode(ByteBuffer buffer) {
+ return model.getQueryTree().encode(buffer);
+ }
+
+ /**
+ * Adds a context message to this query and to the info log,
+ * if the context level of the query is sufficiently high.
+ * The context information will be carried over to the result at creation.
+ * The message parameter will be included <i>with</i> XML escaping.
+ *
+ * @param message the message to add
+ * @param traceLevel the context level of the message, this method will do nothing
+ * if the traceLevel of the query is lower than this value
+ */
+ public void trace(String message, int traceLevel) {
+ trace(message, false, traceLevel);
+ }
+
+ /**
+ * Adds a trace message to this query
+ * if the trace level of the query is sufficiently high.
+ *
+ * @param message the message to add
+ * @param includeQuery true to append the query root stringValue
+ * at the end of the message
+ * @param traceLevel the context level of the message, this method will do nothing
+ * if the traceLevel of the query is lower than this value
+ */
+ public void trace(String message, boolean includeQuery, int traceLevel) {
+ if ( ! isTraceable(traceLevel)) return;
+
+ if (includeQuery)
+ message += ": [" + queryTreeText() + "]";
+
+ log.log(LogLevel.DEBUG,message);
+
+ // Pass 0 as traceLevel as the trace level check is already done above,
+ // and it is not propagated to trace until execution has started
+ // (it is done in the execution.search method)
+ getContext(true).trace(message, 0);
+ }
+
+ /**
+ * Adds a trace message to this query
+ * if the trace level of the query is sufficiently high.
+ *
+ * @param includeQuery true to append the query root stringValue at the end of the message
+ * @param traceLevel the context level of the message, this method will do nothing
+ * if the traceLevel of the query is lower than this value
+ * @param messages the messages whose toStrings will be concatenated into the trace message.
+ * Concatenation will only happen if the trace level is sufficiently high.
+ */
+ public void trace(boolean includeQuery, int traceLevel, Object... messages) {
+ if ( ! isTraceable(traceLevel)) return;
+
+ StringBuilder concatenated = new StringBuilder();
+ for (Object message : messages)
+ concatenated.append(String.valueOf(message));
+ trace(concatenated.toString(), includeQuery, traceLevel);
+ }
+
+ /**
+ * Set the context information for another query to be part of this query's
+ * context information. This is to be used if creating fresh query objects as
+ * part of a plug-in's execution. The query should be attached before it is
+ * used, in case an exception causes premature termination. This is enforced
+ * by an IllegalStateException. In other words, intended use is create the
+ * new query, and attach the context to the invoking query as soon as the new
+ * query is properly initialized.
+ *
+ * <p>
+ * This method will always set the argument query's context level to the context
+ * level of this query.
+ *
+ * @param query
+ * The query which should be traced as a part of this query.
+ * @throws IllegalStateException
+ * If the query given as argument already has context
+ * information.
+ */
+ public void attachContext(Query query) throws IllegalStateException {
+ query.setTraceLevel(getTraceLevel());
+ if (context == null) {
+ // Nothing to attach to. This is about the same as
+ // getTraceLevel() == 0,
+ // but is a direct test of what will make the function superfluous.
+ return;
+ }
+ if (query.getContext(false) != null) {
+ // If we added the other query's context info as a subnode in this
+ // query's context tree, we would have to check for loops in the
+ // context graph. If we simply created a new node without checking,
+ // we might silently overwrite useful information.
+ throw new IllegalStateException("Query to attach already has context information stored.");
+ }
+ query.context = context;
+ }
+
+ private String queryTreeText() {
+ QueryTree root = getModel().getQueryTree();
+
+ if (getTraceLevel() < 2)
+ return root.toString();
+ if (getTraceLevel() < 6)
+ return yqlRepresentation();
+ else
+ return "\n" + yqlRepresentation() + "\n" + new TextualQueryRepresentation(root.getRoot()) + "\n";
+ }
+
+ /**
+ * Serialize this query as YQL+. This method will never throw exceptions,
+ * but instead return a human readable error message if a problem occured
+ * serializing the query. Hits and offset information will be included if
+ * different from default, while linguistics metadata are not added.
+ *
+ * @return a valid YQL+ query string or a human readable error message
+ * @see Query#yqlRepresentation(Tuple2, boolean)
+ */
+ public String yqlRepresentation() {
+ try {
+ return yqlRepresentation(null, true);
+ } catch (NullItemException e) {
+ return "Query currently a placeholder, NullItem encountered.";
+ } catch (RuntimeException e) {
+ return "Failed serializing query as YQL+, please file a ticket including the query causing this: "
+ + Exceptions.toMessageString(e);
+ }
+ }
+
+ private void commaSeparated(StringBuilder yql, Set<String> fields) {
+ int initLen = yql.length();
+ for (String field : fields) {
+ if (yql.length() > initLen) {
+ yql.append(", ");
+ }
+ yql.append(field);
+ }
+ }
+
+ /**
+ * Serialize this query as YQL+. This will create a string representation
+ * which should always be legal YQL+. If a problem occurs, a
+ * RuntimeException is thrown.
+ *
+ * @param segmenterVersion
+ * linguistics metadata used in federation, set to null if the
+ * annotation is not necessary
+ * @param includeHitsAndOffset
+ * whether to include hits and offset parameters converted to a
+ * offset/limit slice
+ * @return a valid YQL+ query string
+ * @throws RuntimeException if there is a problem serializing the query tree
+ */
+ public String yqlRepresentation(@Nullable Tuple2<String, Version> segmenterVersion, boolean includeHitsAndOffset) {
+ String q = VespaSerializer.serialize(this);
+
+ Set<String> sources = getModel().getSources();
+ Set<String> fields = getPresentation().getSummaryFields();
+ StringBuilder yql = new StringBuilder("select ");
+ if (fields.isEmpty()) {
+ yql.append('*');
+ } else {
+ commaSeparated(yql, fields);
+ }
+ yql.append(" from ");
+ if (sources.isEmpty()) {
+ yql.append("sources *");
+ } else {
+ if (sources.size() > 1) {
+ yql.append("sources ");
+ }
+ commaSeparated(yql, sources);
+ }
+ yql.append(" where ");
+ if (segmenterVersion != null) {
+ yql.append("[{\"segmenter\": {\"version\": \"")
+ .append(segmenterVersion.second.toString())
+ .append("\", \"backend\": \"")
+ .append(segmenterVersion.first).append("\"}}](");
+ }
+ yql.append(q);
+ if (segmenterVersion != null) {
+ yql.append(')');
+ }
+ if (getRanking().getSorting() != null && getRanking().getSorting().fieldOrders().size() > 0) {
+ serializeSorting(yql);
+ }
+ if (includeHitsAndOffset) {
+ if (getOffset() != 0) {
+ yql.append(" limit ")
+ .append(Integer.toString(getHits() + getOffset()))
+ .append(" offset ")
+ .append(Integer.toString(getOffset()));
+ } else if (getHits() != 10) {
+ yql.append(" limit ").append(Integer.toString(getHits()));
+ }
+ }
+ if (getTimeout() != 5000L) {
+ yql.append(" timeout ").append(Long.toString(getTimeout()));
+ }
+ yql.append(';');
+ return yql.toString();
+ }
+
+ private void serializeSorting(StringBuilder yql) {
+ yql.append(" order by ");
+ int initLen = yql.length();
+ for (FieldOrder f : getRanking().getSorting().fieldOrders()) {
+ if (yql.length() > initLen) {
+ yql.append(", ");
+ }
+ final Class<? extends AttributeSorter> sorterType = f.getSorter()
+ .getClass();
+ if (sorterType == Sorting.RawSorter.class) {
+ yql.append("[{\"").append(YqlParser.SORTING_FUNCTION)
+ .append("\": \"").append(Sorting.RAW).append("\"}]");
+ } else if (sorterType == Sorting.LowerCaseSorter.class) {
+ yql.append("[{\"").append(YqlParser.SORTING_FUNCTION)
+ .append("\": \"").append(Sorting.LOWERCASE)
+ .append("\"}]");
+ } else if (sorterType == Sorting.UcaSorter.class) {
+ Sorting.UcaSorter uca = (Sorting.UcaSorter) f.getSorter();
+ String ucaLocale = uca.getLocale();
+ Sorting.UcaSorter.Strength ucaStrength = uca.getStrength();
+ yql.append("[{\"").append(YqlParser.SORTING_FUNCTION)
+ .append("\": \"").append(Sorting.UCA).append("\"");
+ if (ucaLocale != null) {
+ yql.append(", \"").append(YqlParser.SORTING_LOCALE)
+ .append("\": \"").append(ucaLocale).append('"');
+ }
+ if (ucaStrength != Sorting.UcaSorter.Strength.UNDEFINED) {
+ yql.append(", \"").append(YqlParser.SORTING_STRENGTH)
+ .append("\": \"").append(ucaStrength.name())
+ .append('"');
+ }
+ yql.append("}]");
+ }
+ yql.append(f.getFieldName());
+ if (f.getSortOrder() == Order.DESCENDING) {
+ yql.append(" desc");
+ }
+ }
+ }
+
+ /** Returns the context of this query, possibly creating it if missing. Returns the context, or null */
+ public QueryContext getContext(boolean create) {
+ if (context==null && create)
+ context=new QueryContext(getTraceLevel(),this);
+ return context;
+ }
+
+ /** Returns a hash of this query based on (some of) its content. */
+ @Override
+ public int hashCode() {
+ return ranking.hashCode()+3*presentation.hashCode()+5* model.hashCode()+ 11*offset+ 13*hits;
+ }
+
+ /** Returns whether the given query is equal to this */
+ @Override
+ public boolean equals(Object other) {
+ if (this==other) return true;
+
+ if ( ! (other instanceof Query)) return false;
+ Query q = (Query) other;
+
+ if (getOffset() != q.getOffset()) return false;
+ if (getHits() != q.getHits()) return false;
+ if ( ! getPresentation().equals(q.getPresentation())) return false;
+ if ( ! getRanking().equals(q.getRanking())) return false;
+ if ( ! getModel().equals(q.getModel())) return false;
+
+ // TODO: Compare property settings
+
+ return true;
+ }
+
+ /** Returns a clone of this query */
+ @Override
+ public Query clone() {
+ Query clone = (Query) super.clone();
+ copyPropertiesTo(clone);
+ return clone;
+ }
+
+ private void copyPropertiesTo(Query clone) {
+ clone.model = model.cloneFor(clone);
+ clone.ranking = (Ranking) ranking.clone();
+ clone.presentation = (Presentation) presentation.clone();
+ clone.context = getContext(true).cloneFor(clone);
+
+ if (errors != null)
+ clone.errors = new ArrayList<>(errors);
+
+ // Correct the Query instance in properties
+ clone.properties().setParentQuery(clone);
+ assert (clone.properties().getParentQuery() == clone);
+
+ clone.setTraceLevel(getTraceLevel());
+ clone.setHits(getHits());
+ clone.setOffset(getOffset());
+ clone.setNoCache(getNoCache());
+ clone.setGroupingSessionCache(getGroupingSessionCache());
+ }
+
+ /** Returns the presentation to be used for this query, never null */
+ public Presentation getPresentation() { return presentation; }
+
+ /** Returns the ranking to be used for this query, never null */
+ public Ranking getRanking() { return ranking; }
+
+ /** Returns the query representation model to be used for this query, never null */
+ public Model getModel() { return model; }
+
+ /**
+ * Return the HTTP request which caused this query. This will never be null
+ * when running with queries from the network.
+ * (Except when running with deprecated code paths, in which case this will
+ * return null but getRequest() will not.)
+ */
+ public HttpRequest getHttpRequest() { return httpRequest; }
+
+ /**
+ * Returns the unique and stable session id of this query.
+ *
+ * @param create if true this is created if not already set
+ * @return the session id of this query, or null if not set and create is false
+ */
+ public SessionId getSessionId(boolean create) {
+ if (sessionId == null && create)
+ this.sessionId = SessionId.next();
+ return sessionId;
+ }
+
+ public boolean hasEncodableProperties() {
+ if ( ! ranking.getProperties().isEmpty()) return true;
+ if ( ! ranking.getFeatures().isEmpty()) return true;
+ if ( ranking.getFreshness() != null) return true;
+ if ( model.getSearchPath() != null) return true;
+ if ( model.getDocumentDb() != null) return true;
+ if ( presentation.getHighlight() != null && ! presentation.getHighlight().getHighlightItems().isEmpty()) return true;
+ return false;
+ }
+
+ /**
+ * Encodes properties of this query.
+ *
+ * @param buffer the buffer to encode to
+ * @param encodeQueryData true to encode all properties, false to only include session information, not actual query data
+ * @return the encoded length
+ */
+ public int encodeAsProperties(ByteBuffer buffer, boolean encodeQueryData) {
+ // Make sure we don't encode anything here if we have turned the property feature off
+ // Due to sendQuery we sometimes end up turning this feature on and then encoding a 0 int as the number of
+ // property maps - that's ok (probably we should simplify by just always turning the feature on)
+ if (! hasEncodableProperties()) return 0;
+
+ int start = buffer.position();
+
+ int mapCountPosition = buffer.position();
+ buffer.putInt(0); // map count will go here
+
+ int mapCount = 0;
+
+ // TODO: Push down
+ mapCount += ranking.getProperties().encode(buffer, encodeQueryData);
+ if (encodeQueryData) mapCount += ranking.getFeatures().encode(buffer);
+
+ // TODO: Push down
+ if (encodeQueryData && presentation.getHighlight() != null) mapCount += MapEncoder.encodeStringMultiMap(Highlight.HIGHLIGHTTERMS, presentation.getHighlight().getHighlightTerms(), buffer);
+
+ // TODO: Push down
+ if (encodeQueryData) mapCount += MapEncoder.encodeSingleValue("model", "searchpath", model.getSearchPath(), buffer);
+ mapCount += MapEncoder.encodeSingleValue(DocumentDatabase.MATCH_PROPERTY, DocumentDatabase.SEARCH_DOC_TYPE_KEY, model.getDocumentDb(), buffer);
+
+ mapCount += MapEncoder.encodeMap("caches", createCacheSettingMap(), buffer);
+
+ buffer.putInt(mapCountPosition, mapCount);
+
+ return buffer.position() - start;
+ }
+
+ private Map<String, Boolean> createCacheSettingMap() {
+ if (getGroupingSessionCache() && ranking.getQueryCache()) {
+ Map<String, Boolean> cacheSettingMap = new HashMap<>();
+ cacheSettingMap.put("grouping", true);
+ cacheSettingMap.put("query", true);
+ return cacheSettingMap;
+ }
+ if (getGroupingSessionCache())
+ return Collections.singletonMap("grouping", true);
+ if (ranking.getQueryCache())
+ return Collections.singletonMap("query", true);
+ return Collections.<String,Boolean>emptyMap();
+ }
+
+ /**
+ * Prepares this for binary serialization.
+ * <p>
+ * This must be invoked after all changes have been made to this query before it is passed
+ * on to a receiving backend. Calling it is somewhat expensive, so it should only happen once.
+ * If a prepared query is cloned, it stays prepared.
+ */
+ public void prepare() {
+ getModel().prepare(getRanking());
+ getPresentation().prepare();
+ getRanking().prepare();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/Result.java b/container-search/src/main/java/com/yahoo/search/Result.java
new file mode 100644
index 00000000000..b6a88200084
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/Result.java
@@ -0,0 +1,365 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search;
+
+import com.yahoo.collections.ListMap;
+import com.yahoo.net.URI;
+import com.yahoo.protect.Validator;
+import com.yahoo.search.query.context.QueryContext;
+import com.yahoo.search.result.*;
+import com.yahoo.search.statistics.ElapsedTime;
+
+import java.util.Iterator;
+
+/**
+ * The Result contains all the data produced by executing a Query: Some very limited global information, and
+ * a single HitGroup containing hits of the result. The HitGroup may contain Hits, which are the individual
+ * result items, as well as further HitGroups, making up a <i>composite</i> structure. This allows the hits of a result
+ * to be hierarchically organized. A Hit is polymorphic and may contain any kind of information deemed
+ * an approriate partial answer to the Query.
+ *
+ * @author bratseth
+ */
+public final class Result extends com.yahoo.processing.Response implements Cloneable {
+
+ // Note to developers: If you think you should add something here you are probably wrong
+ // To add some new kind of data, create a Hit subclass carrying the data and add that instead
+
+ /** The top level hit group of this result */
+ private HitGroup hits;
+
+ /** The estimated total number of hits which would in theory be displayed this result is a part of */
+ private long totalHitCount;
+
+ /**
+ * The estimated total number of <i>deep</i> hits, which includes every object which matches the query.
+ * This is always at least the same as totalHitCount. A lower value will cause hitCount to be returned.
+ */
+ private long deepHitCount;
+
+ /** The time spent producing this result */
+ private ElapsedTime timeAccountant = new ElapsedTime();
+
+ /** Coverage information for this result. */
+ private Coverage coverage = null;
+
+ /**
+ * Headers containing "envelope" meta information to be returned with this result.
+ * Used for HTTP getHeaders when the return protocol is HTTP.
+ */
+ private ListMap<String,String> headers=null;
+
+ /**
+ * Result rendering infrastructure.
+ */
+ private final Templating templating;
+
+ /** Creates a new Result where the top level hit group has id "toplevel" */
+ public Result(Query query) {
+ this(query, new HitGroup("toplevel"));
+ }
+
+ /**
+ * Create an empty result.
+ * A source creating a result is <b>required</b> to call
+ * {@link #setTotalHitCount} before releasing this result.
+ *
+ * @param query the query which produced this result
+ * @param hits the hit container which this will return from {@link #hits()}
+ */
+ @SuppressWarnings("deprecation")
+ public Result(Query query, HitGroup hits) {
+ super(query);
+ if (query==null) throw new NullPointerException("The query reference in a result cannot be null");
+ this.hits=hits;
+ hits.setQuery(query);
+ if (query.getRanking().getSorting() != null) {
+ setHitOrderer(new HitSortOrderer(query.getRanking().getSorting()));
+ }
+ templating = new Templating(this);
+ }
+
+ /** Create a result containing an error */
+ public Result(Query query, ErrorMessage errorMessage) {
+ this(query);
+ hits.setError(errorMessage);
+ }
+
+ /**
+ * Merges <b>meta information</b> from a result into this.
+ * This does not merge hits, but the other information associated
+ * with a result. It should <b>always</b> be called when adding
+ * hits from a result, but there is no constraints on the order of the calls.
+ */
+ @SuppressWarnings("deprecation")
+ public void mergeWith(Result result) {
+ if (templating.usesDefaultTemplate())
+ templating.setRenderer(result.templating.getRenderer());
+ totalHitCount += result.getTotalHitCount();
+ deepHitCount += result.getDeepHitCount();
+ timeAccountant.merge(result.getElapsedTime());
+ boolean create=true;
+ if (result.getCoverage(!create) != null || getCoverage(!create) != null)
+ getCoverage(create).merge(result.getCoverage(create));
+ }
+
+ /**
+ * Merges meta information produced when a Hit already
+ * contained in this result has been filled using another
+ * result as an intermediary. @see mergeWith(Result) mergeWith.
+ */
+ public void mergeWithAfterFill(Result result) {
+ timeAccountant.merge(result.getElapsedTime());
+ }
+
+ /**
+ * Returns the number of hit objects available in the top level group of this result.
+ * Note that this number is allowed to be higher than the requested number
+ * of hits, because a searcher is allowed to add <i>meta</i> hits as well
+ * as the requested number of concrete hits.
+ */
+ public int getHitCount() {
+ return hits.size();
+ }
+
+ /**
+ * <p>Returns the total number of concrete hits contained (directly or in subgroups) in this result.
+ * This should equal the requested hits count if the query has that many matches.</p>
+ */
+ public int getConcreteHitCount() {
+ return hits.getConcreteSize();
+ }
+
+ /**
+ * Returns the <b>estimated</b> total number of concrete hits which would be returned for this query.
+ */
+ public long getTotalHitCount() {
+ return totalHitCount;
+ }
+
+ /**
+ * Returns the estimated total number of <i>deep</i> hits, which includes every object which matches the query.
+ * This is always at least the same as totalHitCount. A lower value will cause hitCount to be returned.
+ */
+ public long getDeepHitCount() {
+ if (deepHitCount<totalHitCount) return totalHitCount;
+ return deepHitCount;
+ }
+
+
+ /** Sets the estimated total number of hits this result is a subset of */
+ public void setTotalHitCount(long totalHitCount) {
+ this.totalHitCount = totalHitCount;
+ }
+
+ /** Sets the estimated total number of deep hits this result is a subset of */
+ public void setDeepHitCount(long deepHitCount) {
+ this.deepHitCount = deepHitCount;
+ }
+
+ public ElapsedTime getElapsedTime() {
+ return timeAccountant;
+ }
+
+ public void setElapsedTime(ElapsedTime t) {
+ timeAccountant = t;
+ }
+
+ /**
+ * Returns true only if _all_ hits in this result originates from a cache.
+ */
+ public boolean isCached() {
+ return hits.isCached();
+ }
+
+ /**
+ * Returns whether all hits in this result have been filled with
+ * the properties contained in the given summary class. Note that
+ * this method will also return true if no hits in this result are
+ * fillable.
+ */
+ public boolean isFilled(String summaryClass) {
+ return hits.isFilled(summaryClass);
+ }
+
+ /** Returns the query which produced this result */
+ public Query getQuery() { return hits.getQuery(); }
+
+ /** Sets a query for this result */
+ public void setQuery(Query query) { hits.setQuery(query); }
+
+ /**
+ * <p>Sets the hit orderer to be used for the top level hit group.</p>
+ *
+ * @param hitOrderer the new hit orderer, or null to use default relevancy ordering
+ */
+ public void setHitOrderer(HitOrderer hitOrderer) { hits.setOrderer(hitOrderer); }
+
+ /** Returns the orderer used by the top level group, or null if the default relevancy order is used */
+ public HitOrderer getHitOrderer() { return hits.getOrderer(); }
+
+ public void setDeletionBreaksOrdering(boolean flag) { hits.setDeletionBreaksOrdering(flag); }
+
+ public boolean getDeletionBreaksOrdering() { return hits.getDeletionBreaksOrdering(); }
+
+ /** Update cached and filled by iterating through the hits of this result */
+ public void analyzeHits() { hits.analyze(); }
+
+ /** Returns the top level hit group containing all the hits of this result */
+ public HitGroup hits() { return hits; }
+
+ @Override
+ public com.yahoo.processing.response.DataList<?> data() {
+ return hits;
+ }
+
+
+ /** Sets the top level hit group containing all the hits of this result */
+ public void setHits(HitGroup hits) {
+ Validator.ensureNotNull("The top-level hit group of " + this,hits);
+ this.hits=hits;
+ }
+
+ /**
+ * Deep clones this result - copies are made of all hits and subgroups of hits,
+ * <i>but not of the query referenced by this</i>.
+ */
+ public Result clone() {
+ Result resultClone = (Result) super.clone();
+
+ resultClone.hits = hits.clone();
+
+ resultClone.getTemplating().setRenderer(null); // TODO: Kind of wrong
+ resultClone.setElapsedTime(new ElapsedTime());
+ return resultClone;
+ }
+
+
+ public String toString() {
+ if (hits.getError() != null) {
+ return "Result: " + hits.getErrorHit().errors().iterator().next();
+ } else {
+ return "Result (" + getConcreteHitCount() + " of total " + getTotalHitCount() + " hits)";
+ }
+ }
+
+ /**
+ * Adds a context message to this query containing the entire content of this result,
+ * if tracelevel is 5 or more.
+ *
+ * @param name the name of the searcher instance returning this result
+ */
+ public void trace(String name) {
+ if (hits().getQuery().getTraceLevel() < 5) {
+ return;
+ }
+ StringBuilder hitBuffer = new StringBuilder(name);
+
+ hitBuffer.append(" returns:\n");
+ int counter = 0;
+
+ for (Iterator<Hit> i = hits.unorderedIterator(); i.hasNext();) {
+ Hit hit = i.next();
+
+ if (hit.isMeta()) continue;
+
+ hitBuffer.append(" #: ");
+ hitBuffer.append(counter);
+
+ traceExtraHitProperties(hitBuffer, hit);
+
+ hitBuffer.append(", relevancy: ");
+ hitBuffer.append(hit.getRelevance());
+
+ hitBuffer.append(", addno: ");
+ hitBuffer.append(hit.getAddNumber());
+
+ hitBuffer.append(", source: ");
+ hitBuffer.append(hit.getSource());
+
+ hitBuffer.append(", uri: ");
+ URI uri = hit.getId();
+
+ if (uri != null) {
+ hitBuffer.append(uri.getHost());
+ } else {
+ hitBuffer.append("(no uri)");
+ }
+ hitBuffer.append("\n");
+ counter++;
+ }
+ if (counter == 0) {
+ hitBuffer.append("(no hits)\n");
+ }
+ hits.getQuery().trace(hitBuffer.toString(), false, 5);
+ }
+
+ /**
+ * For tracing custom properties of a hit, see trace(String). An example of
+ * using this is in com.yahoo.prelude.Result.
+ *
+ * @param hitBuffer
+ * the render target
+ * @param hit
+ * the hit to be analyzed
+ */
+ protected void traceExtraHitProperties(StringBuilder hitBuffer, Hit hit) {
+ return;
+ }
+
+ /** Returns the context of this result - this is equal to getQuery().getContext(create) */
+ public QueryContext getContext(boolean create) { return getQuery().getContext(create); }
+
+ public void setCoverage(Coverage coverage) { this.coverage = coverage; }
+
+ // Coverage a part of tracing?
+ // Coverage logic might me moved around, but it should not be a part of tracing.
+ // Coverage is status information about access to a corpus, tracing is voluntary,
+ // diagnostic search status.
+ /**
+ * Returns coverage information
+ *
+ * @param create if true the coverage information of this result is created if missing
+ * @return the coverage information of this, or null if none and create is false
+ */
+ public Coverage getCoverage(boolean create) {
+ if (coverage == null && create) {
+ if (hits.getError() == null) {
+ // No error here implies full coverage.
+ // Don't count this as a result set if there's no data - avoid counting empty results made
+ // to simplify code paths
+ coverage = new Coverage(0L, 0, true, (hits().size()==0 ? 0 : 1));
+ } else {
+ coverage = new Coverage(0L, 0, false);
+ }
+ }
+ return coverage;
+ }
+
+ /**
+ * Returns the set of "envelope" headers to be returned with this result.
+ * This returns the live map in modifiable form - modify this to change the
+ * headers. Or null if none, and it should not be created.
+ * <p>
+ * Used for HTTP headers when the return protocol is HTTP, e.g
+ * <pre>result.getHeaders(true).put("Cache-Control","max-age=120")</pre>
+ *
+ * @param create if true, create the header ListMap if it does not exist
+ * @return returns the ListMap of current headers, or null if no headers are set and <pre>create</pre> is false
+ */
+ public ListMap<String, String> getHeaders(boolean create) {
+ if (headers == null && create)
+ headers = new ListMap<>();
+ return headers;
+ }
+
+ /**
+ * The Templating object contains helper methods and data containers for
+ * result rendering.
+ *
+ * @return helper object for result rendering
+ */
+ public Templating getTemplating() {
+ return templating;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/Searcher.java b/container-search/src/main/java/com/yahoo/search/Searcher.java
new file mode 100644
index 00000000000..95b4f92ca56
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/Searcher.java
@@ -0,0 +1,175 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.processing.Processor;
+import com.yahoo.processing.Response;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.logging.Logger;
+
+/**
+ * Superclass of all {@link com.yahoo.component.Component Components} which produces Results in response to
+ * Queries by calling the {@link #search search} method.
+ * <p>
+ * Searchers are participants in <i>chain of responsibility</i> {@link com.yahoo.search.searchchain.SearchChain search chains}
+ * where they passes the Queries downwards by synchroneously calling the next Searcher in the chain, and returns the
+ * Results back up as the response.
+ * <p>
+ * Any Searcher may
+ * <ul>
+ * <li>Do modifications to the Query before passing it on (a <i>query rerwiter</i>)
+ * <li>Do modifications to the Result before passing it on up, e.g removing altering, reorganizing or adding Hits
+ * (a <i>result processor</i>)
+ * <li>Pass the Query on to multiple other search chains, either in series
+ * (by creating a new {@link com.yahoo.search.searchchain.Execution} for each chain), or in parallel (by creating a
+ * {@link com.yahoo.search.searchchain.AsyncExecution}) (a <i>federator</i>)
+ * <li>Create a Result and pass it back up, either by calling some other node(s) to get the data, or by creating the
+ * Result from internal data (a <i>source</i>)
+ * <li>Pass some query on downwards multiple times, or in different ways, typically each time depending of the Result
+ * returned the last time (a <i>workflow</i>)
+ * </ul>
+ *
+ * <p>...or some combination of the above of course. Note that as Searchers work synchronously, any information can be
+ * retained on the stack in the Searcher from the Query is received until the Result is returned simply by declaring
+ * variables for the data in the search method (or whatever it calls), and for the same reason workflows are
+ * implemented as Java code. However, searchers are executed by many threads, for different Queries, in parallell, so
+ * any mutable data shared between queries (and hence stored as instance members must be accessed multithread safely.
+ * In many cases, shared data can simply be instantiated in the constructor and used in read-only mode afterwards
+ * <p>
+ * <b>Searcher lifecycle:</b> A searcher has a simple life-cycle:
+ *
+ * <ul>
+ * <li><b>Construction: While a constructor is running.</b> A searcher is handed its id and configuration
+ * (if any) in the constructor. During construction, the searcher should build any in-memory structures needed.
+ * A new instance of the searcher will be created when the configuration is changed.
+ * Constructors are called with this priority:
+ *
+ * <ul>
+ * <li>The constructor taking a ComponentId, followed by the highest number of config classes (subclasses of
+ * {@link com.yahoo.config.ConfigInstance}) as arguments.
+ * <li>The constructor taking a string id followed by the highest number of config classes as arguments.
+ * <li>The constructor taking only the highest number of config classes as arguments.
+ * <li>The constructor taking a ComponentId as the only argument
+ * <li>The constructor taking a string id as the only argument
+ * <li>The default (no-argument) constructor.
+ * </ul>
+ *
+ * If none of these constructors are declared, searcher construction will fail.
+ *
+ * <li><b>In service: After the constructor has returned.</b> In this phase, searcher service methods are
+ * called at any time by multiple threads in parallel.
+ * Implementations should avoid synchronization and access to volatiles as much as possible by keeping
+ * data structures build in construction read-only.
+ *
+ * <li><b>Deconstruction: While deconstruct is running.</b> All Searcher service method calls have completed when
+ * this method is called. When it returns, the searcher will be eligible for garbage collection.
+ *
+ * </ul>
+ *
+ * @author bratseth
+ */
+public abstract class Searcher extends Processor {
+
+ // Note to developers: If you think you should add something here you are probably wrong
+ // Create a subclass containing the new method instead.
+
+ private final Logger logger = Logger.getLogger(getClass().getName());
+
+ public Searcher() {}
+
+ /** Creates a searcher from an id */
+ public Searcher(ComponentId id) {
+ super();
+ initId(id);
+ }
+
+ /**
+ * Override this to implement your searcher.
+ * <p>
+ * Searcher implementation subclasses will, depending on their type of logic, do one of the following:
+ * <ul>
+ * <li><b>Query processors:</b> Access the query, then call execution.search and return the result
+ * <li><b>Result processors:</b> Call execution.search to get the result, access it and return
+ * <li><b>Sources</b> (which produces results): Create a result, add the desired hits and return it.
+ * <li><b>Federators</b> (which forwards the search to multiple subchains): Call search on the
+ * desired subchains in parallel and get the results. Combine the results to one and return it.
+ * <li><b>Workflows:</b> Call execution.search as many times as desired, using different queries.
+ * Eventually return a result.
+ * </ul>
+ * <p>
+ * Hits come in two kinds - <i>concrete hits</i> are actual
+ * content of the kind requested by the user, <i>meta hits</i> are
+ * hits which provides information about the collection of hits,
+ * on the query, the service and so on.
+ * <p>
+ * The query specifies a window into a larger result list that must be returned from the searcher
+ * through <i>hits</i> and <i>offset</i>;
+ * Searchers which returns list of hits in the top level in the result
+ * must return at least <i>hits</i> number of hits (or if impossible; all that are available),
+ * starting at the given offset.
+ * In addition, searchers are allowed to return
+ * any number of meta hits (although this number is expected to be low).
+ * For hits contained in nested hit groups, the concept of a window defined by hits and offset
+ * is not well defined and does not apply.
+ * <p>
+ * Error handling in searchers:
+ * <ul>
+ * <li>Unexpected events: Throw any RuntimeException. This query will fail
+ * with the exception message, and the error will be logged
+ * <li>Expected events: Create (new Result(Query, ErrorMessage) or add
+ * result.setErrorIfNoOtherErrors(ErrorMessage) an error message to the Result.
+ * <li>Recoverable user errors: Add a FeedbackHit explaining the condition
+ * and how to correct it.
+ * </ul>
+ *
+ * @param query the query
+ * @return the result of making this query
+ */
+ public abstract Result search(Query query,Execution execution);
+
+ /** Use the search method in Searcher processors. This forwards to it. */
+ @Override
+ public final Response process(com.yahoo.processing.Request request, com.yahoo.processing.execution.Execution execution) {
+ return search((Query)request,(Execution)execution);
+ }
+
+ /**
+ * Fill hit properties with data using the given summary class.
+ * Calling this on already filled results has no cost.
+ * <p>
+ * This needs to be overridden by <i>federating</i> searchers to contact search sources again by
+ * propagating the fill call down through the search chain, and by <i>source</i> searchers
+ * which talks to fill capable backends to request the data to be filled. Other searchers do
+ * not need to override this.
+ *
+ * @param result the result to fill
+ * @param summaryClass the name of the collection of fields to fetch the values of, or null to use the default
+ */
+ public void fill(Result result, String summaryClass, Execution execution) {
+ execution.fill(result,summaryClass);
+ }
+
+ /**
+ * Fills the result if it is not already filled for the given summary class.
+ * See the fill method.
+ **/
+ public final void ensureFilled(Result result, String summaryClass, Execution execution) {
+ if (summaryClass == null)
+ summaryClass = result.getQuery().getPresentation().getSummary();
+
+ if (!result.isFilled(summaryClass)) {
+ fill(result, summaryClass, execution);
+ }
+ }
+
+ /** Returns a logger unique for the instance subclass */
+ protected Logger getLogger() { return logger; }
+
+ /** Returns "searcher 'getId()'" */
+ public @Override String toString() {
+ return "searcher '" + getIdString() + "'";
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/app/.gitignore b/container-search/src/main/java/com/yahoo/search/app/.gitignore
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/app/.gitignore
diff --git a/container-search/src/main/java/com/yahoo/search/cache/package-info.java b/container-search/src/main/java/com/yahoo/search/cache/package-info.java
new file mode 100644
index 00000000000..292b491c52b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cache/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Cache package, exported to keep the ignored legacy cache config around until Vespa 7.
+ *
+ * @author bratseth
+ */
+@ExportPackage
+package com.yahoo.search.cache;
+
+import com.yahoo.osgi.annotation.ExportPackage; \ No newline at end of file
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/BaseNodeMonitor.java b/container-search/src/main/java/com/yahoo/search/cluster/BaseNodeMonitor.java
new file mode 100644
index 00000000000..de67369a231
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/BaseNodeMonitor.java
@@ -0,0 +1,93 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+import java.util.logging.Logger;
+
+import com.yahoo.search.result.ErrorMessage;
+
+
+/**
+ * A node monitor is responsible for maintaining the state of a monitored node.
+ * It has the following properties:
+ * <ul>
+ * <li>A node is taken out of operation if it fails</li>
+ * <li>A node is put back in operation when it responds correctly again
+ * <i>responseAfterFailLimit</i> times <b>unless</b>
+ * it has failed <i>failQuarantineLimit</i>. In the latter case it won't
+ * be put into operation again before that time period has expired</li>
+ * </ul>
+ *
+ * @author bratseth
+ */
+public abstract class BaseNodeMonitor<T> {
+
+ protected static Logger log=Logger.getLogger(BaseNodeMonitor.class.getName());
+
+ /** The object representing the monitored node */
+ protected T node;
+
+ protected boolean isWorking=true;
+
+ /** Whether this node is quarantined for unstability */
+ protected boolean isQuarantined=false;
+
+ /** The last time this node failed, in ms */
+ protected long failedAt=0;
+
+ /** The last time this node responded (failed or succeeded), in ms */
+ protected long respondedAt=0;
+
+ /** The last time this node responded successfully */
+ protected long succeededAt=0;
+
+ /** The configuration of this monitor */
+ protected MonitorConfiguration configuration;
+
+ /** Is the node we monitor part of an internal Vespa cluster or not */
+ private boolean internal=false;
+
+ public BaseNodeMonitor(boolean internal) {
+ this.internal=internal;
+ }
+
+ public T getNode() { return node; }
+
+ /**
+ * Returns whether this node is currently in a state suitable
+ * for receiving traffic. As far as we know, that is
+ */
+ public boolean isWorking() { return isWorking; }
+
+ public boolean isQuarantined() { return isQuarantined; }
+
+ /**
+ * Called when this node fails.
+ *
+ * @param error a description of the error
+ */
+ public abstract void failed(ErrorMessage error);
+
+ /**
+ * Called when a response is received from this node. If the node was
+ * quarantined and it has been in that state for more than QuarantineTime
+ * milliseconds, it is taken out of quarantine.
+ *
+ * if it is not in quarantine but is not working, it may be set to working
+ * if this method is called at least responseAfterFailLimit times
+ */
+ public abstract void responded();
+
+ public boolean isIdle() {
+ return (now()-respondedAt) >= configuration.getIdleLimit();
+ }
+
+ protected long now() {
+ return System.currentTimeMillis();
+ }
+
+ /** Thread-safely changes the state of this node if required */
+ protected abstract void setWorking(boolean working,String explanation);
+
+ /** Returns whether or not this is monitoring an internal node. Default is false. */
+ public boolean isInternal() { return internal; }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/ClusterMonitor.java b/container-search/src/main/java/com/yahoo/search/cluster/ClusterMonitor.java
new file mode 100644
index 00000000000..1c50ea5d904
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/ClusterMonitor.java
@@ -0,0 +1,157 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+
+import com.yahoo.concurrent.DaemonThreadFactory;
+import com.yahoo.concurrent.ThreadFactoryFactory;
+import com.yahoo.search.result.ErrorMessage;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Monitors of a cluster of remote nodes.
+ * The monitor uses an internal thread for node monitoring.
+ * All <i>public</i> methods of this class are multithread safe.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class ClusterMonitor<T> {
+
+ private MonitorConfiguration configuration=new MonitorConfiguration();
+
+ private static Logger log=Logger.getLogger(ClusterMonitor.class.getName());
+
+ private NodeManager<T> nodeManager;
+
+ private MonitorThread monitorThread;
+
+ private volatile boolean shutdown = false;
+
+ /** A map from Node to corresponding MonitoredNode */
+ private Map<T,BaseNodeMonitor<T>> nodeMonitors=
+ Collections.synchronizedMap(new java.util.LinkedHashMap<T, BaseNodeMonitor<T>>());
+
+ public ClusterMonitor(NodeManager<T> manager, String monitorConfigID) {
+ nodeManager=manager;
+ monitorThread=new MonitorThread("search.clustermonitor");
+ monitorThread.start();
+ log.fine("checkInterval is " + configuration.getCheckInterval()+" ms");
+ }
+
+ /** Returns the configuration of this cluster monitor */
+ public MonitorConfiguration getConfiguration() { return configuration; }
+
+ /**
+ * Adds a new node for monitoring.
+ * The object representing the node must
+ * <ul>
+ * <li>Have a sensible toString</li>
+ * <li>Have a sensible identity (equals and hashCode)</li>
+ * </ul>
+ *
+ * @param node the object representing the node
+ * @param internal whether or not this node is internal to this cluster
+ */
+ public void add(T node,boolean internal) {
+ BaseNodeMonitor<T> monitor=new TrafficNodeMonitor<>(node,configuration,internal);
+ // BaseNodeMonitor monitor=new NodeMonitor(node,configuration);
+ nodeMonitors.put(node,monitor);
+ }
+
+ /**
+ * Returns the monitor of the given node, or null if this node has not been added
+ */
+ public BaseNodeMonitor<T> getNodeMonitor(T node) {
+ return nodeMonitors.get(node);
+ }
+
+ /** Called from ClusterSearcher/NodeManager when a node failed */
+ public synchronized void failed(T node, ErrorMessage error) {
+ BaseNodeMonitor<T> monitor=nodeMonitors.get(node);
+ boolean wasWorking=monitor.isWorking();
+ monitor.failed(error);
+ if (wasWorking && !monitor.isWorking()) {
+ nodeManager.failed(node);
+ }
+ }
+
+ /** Called when a node responded */
+ public synchronized void responded(T node) {
+ BaseNodeMonitor<T> monitor = nodeMonitors.get(node);
+ boolean wasFailing=!monitor.isWorking();
+ monitor.responded();
+ if (wasFailing && monitor.isWorking()) {
+ nodeManager.working(monitor.getNode());
+ }
+ }
+
+ /**
+ * Ping all nodes which needs pinging to discover state changes
+ */
+ public void ping(Executor executor) {
+ for (Iterator<BaseNodeMonitor<T>> i=nodeMonitorIterator(); i.hasNext(); ) {
+ BaseNodeMonitor<T> monitor= i.next();
+ // always ping
+ // if (monitor.isIdle())
+ nodeManager.ping(monitor.getNode(),executor); // Cause call to failed or responded
+ }
+ }
+
+ /** Returns a thread-safe snapshot of the NodeMonitors of all added nodes */
+ public Iterator<BaseNodeMonitor<T>> nodeMonitorIterator() {
+ return nodeMonitors().iterator();
+ }
+
+ /** Returns a thread-safe snapshot of the NodeMonitors of all added nodes */
+ public List<BaseNodeMonitor<T>> nodeMonitors() {
+ synchronized (nodeMonitors) {
+ return new java.util.ArrayList<>(nodeMonitors.values());
+ }
+ }
+
+ /** Must be called when this goes out of use */
+ public void shutdown() {
+ shutdown = true;
+ monitorThread.interrupt();
+ }
+
+ private class MonitorThread extends Thread {
+ MonitorThread(String name) {
+ super(name);
+ }
+
+ public void run() {
+ log.fine("Starting cluster monitor thread");
+ // Pings must happen in a separate thread from this to handle timeouts
+ // By using a cached thread pool we ensured that 1) a single thread will be used
+ // for all pings when there are no problems (important because it ensures that
+ // any thread local connections are reused) 2) a new thread will be started to execute
+ // new pings when a ping is not responding
+ Executor pingExecutor=Executors.newCachedThreadPool(ThreadFactoryFactory.getDaemonThreadFactory("search.ping"));
+ while (!isInterrupted()) {
+ try {
+ Thread.sleep(configuration.getCheckInterval());
+ log.finest("Activating ping");
+ ping(pingExecutor);
+ }
+ catch (Exception e) {
+ if (shutdown && e instanceof InterruptedException) {
+ break;
+ } else {
+ log.log(Level.WARNING,"Error in monitor thread",e);
+ }
+ }
+ }
+ log.fine("Stopped cluster monitor thread");
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/ClusterSearcher.java b/container-search/src/main/java/com/yahoo/search/cluster/ClusterSearcher.java
new file mode 100644
index 00000000000..da3d0d8e20b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/ClusterSearcher.java
@@ -0,0 +1,374 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.container.protect.Error;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.Ping;
+import com.yahoo.prelude.Pong;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.cluster.Hasher.NodeList;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.List;
+import java.util.concurrent.*;
+
+/**
+ * Implements clustering (failover and load balancing) over a set of client
+ * connections to a homogenous cluster of nodes. Searchers which wants to make
+ * clustered connections to some service should use this.
+ * <p>
+ * This replaces the usual searcher methods by ones which have the same contract
+ * and semantics but which takes an additional parameter which is the Connection
+ * selected by the cluster searcher which the method should use. Overrides of
+ * these connection methods <i>must not</i> call the super methods to pass on
+ * but must use the methods on execution.
+ * <p>
+ * The type argument is the class (of any type) representing the connections.
+ * The connection objects should implement a good toString to ease diagnostics.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+public abstract class ClusterSearcher<T> extends PingableSearcher implements NodeManager<T> {
+
+ private Hasher<T> hasher = new Hasher<>();
+ private ClusterMonitor<T> monitor = new ClusterMonitor<>(this, "dummy");
+
+ /**
+ * Creates a new cluster searcher
+ *
+ * @param id
+ * the id of this searcher
+ * @param connections
+ * the connections of the cluster
+ * @param internal
+ * whether or not this cluster is internal (part of the same
+ * installation)
+ */
+ public ClusterSearcher(ComponentId id, List<T> connections, boolean internal) {
+ this(id, connections, new Hasher<T>(), internal);
+ }
+
+ public ClusterSearcher(ComponentId id, List<T> connections, Hasher<T> hasher, boolean internal) {
+ super(id);
+ this.hasher = hasher;
+ for (T connection : connections) {
+ monitor.add(connection, internal);
+ hasher.add(connection);
+ }
+ }
+
+ /**
+ * Pinging a node by sending a query NodeManager method, called from
+ * ClusterMonitor
+ */
+ public final @Override void ping(T p, Executor executor) {
+ log(LogLevel.FINE, "Sending ping to: ", p);
+ Pinger pinger = new Pinger(p);
+ FutureTask<Pong> future = new FutureTask<>(pinger);
+
+ executor.execute(future);
+ Pong pong;
+ Throwable logThrowable = null;
+
+ try {
+ pong = future.get(monitor.getConfiguration().getFailLimit(),
+ TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ pong = new Pong();
+ pong.addError(ErrorMessage
+ .createUnspecifiedError("Ping was interrupted: " + p));
+ logThrowable = e;
+ } catch (ExecutionException e) {
+ pong = new Pong();
+ pong.addError(ErrorMessage
+ .createUnspecifiedError("Execution was interrupted: " + p));
+ logThrowable = e;
+ } catch (LinkageError e) { // Typically Osgi woes
+ pong = new Pong();
+ pong.addError(ErrorMessage.createErrorInPluginSearcher("Class loading problem",e));
+ logThrowable=e;
+ } catch (TimeoutException e) {
+ pong = new Pong();
+ pong.addError(ErrorMessage
+ .createNoAnswerWhenPingingNode("Ping thread timed out."));
+ }
+ future.cancel(true);
+
+ if (pong.badResponse()) {
+ monitor.failed(p, pong.getError(0));
+ log(LogLevel.FINE, "Failed ping - ", pong);
+ } else {
+ monitor.responded(p);
+ log(LogLevel.FINE, "Answered ping - ", p);
+ }
+
+ if (logThrowable != null) { // This looks strange, but yes - it is
+ // needed
+ String logMsg;
+ if (logThrowable instanceof TimeoutException) {
+ logMsg = "Ping timed out for " + getId().getName() + ".";
+ } else {
+ StackTraceElement[] trace = logThrowable.getStackTrace();
+ String traceAsString = null;
+ if (trace != null) {
+ StringBuilder b = new StringBuilder(": ");
+ for (StackTraceElement k : trace) {
+ if (k == null) {
+ b.append("null\n");
+ } else {
+ b.append(k.toString()).append('\n');
+ }
+ }
+ traceAsString = b.toString();
+ }
+ logMsg = "Caught " + logThrowable.getClass().getName()
+ + " exception in " + getId().getName() + " ping"
+ + (trace == null ? ", no stack trace available." : traceAsString);
+ }
+ getLogger().warning(logMsg);
+ }
+
+ }
+
+ /**
+ * Pings this connection. Pings may be sent "out of band" at any time by the
+ * monitoring subsystem to determine the status of this connection. If the
+ * ping fails, it is ok both to set an error in the pong or to throw an
+ * exception.
+ */
+ protected abstract Pong ping(Ping ping, T connection);
+
+ protected T getFirstConnection(NodeList<T> nodes, int code, int trynum, Query query) {
+ return nodes.select(code, trynum);
+ }
+
+ @Override
+ public final Result search(Query query, Execution execution) {
+ int tries = 0;
+
+ Hasher.NodeList<T> nodes = getHasher().getNodes();
+
+ if (nodes.getNodeCount() == 0)
+ return search(query, execution, ErrorMessage
+ .createNoBackendsInService("No nodes in service in " + this + " (" + monitor.nodeMonitors().size()
+ + " was configured, none is responding)"));
+
+ int code = query.hashCode();
+ Result result;
+ T connection = getFirstConnection(nodes, code, tries, query);
+ do {
+ // The loop is in case there are other searchers available
+ // able to produce results
+ if (connection == null)
+ return search(query, execution, ErrorMessage
+ .createNoBackendsInService("No in node could handle " + query + " according to " +
+ hasher + " in " + this));
+ if (timedOut(query))
+ return new Result(query, ErrorMessage.createTimeout("No time left for searching"));
+
+ if (query.getTraceLevel() >= 8)
+ query.trace("Trying " + connection, false, 8);
+
+ result = robustSearch(query, execution, connection);
+
+ if (!shouldRetry(query, result))
+ return result;
+
+ if (query.getTraceLevel() >= 6)
+ query.trace("Error from connection " + connection + " : " + result.hits().getError(), false, 6);
+
+ if (result.hits().getError().getCode() == Error.TIMEOUT.code)
+ return result; // Retry is unlikely to help
+
+ log(LogLevel.FINER, "No result, checking for timeout.");
+ tries++;
+ connection = nodes.select(code, tries);
+ } while (tries < nodes.getNodeCount());
+
+ // only error result gets returned here.
+ return result;
+
+ }
+
+ /**
+ * Returns whether this query and result should be retried against another
+ * connection if possible. This default implementation returns true if the
+ * result contains some error.
+ */
+ protected boolean shouldRetry(Query query, Result result) {
+ return result.hits().getError() != null;
+ }
+
+ /**
+ * This is called (instead of search(quer,execution,connextion) to handle
+ * searches where no (suitable) backend was available. The default
+ * implementation returns an error result.
+ */
+ protected Result search(Query query, Execution execution, ErrorMessage message) {
+ return new Result(query, message);
+ }
+
+ /**
+ * Call search(Query,Execution,T) and handle any exceptions returned which
+ * we do not want to propagate upwards By default this catches all runtime
+ * exceptions and puts them into the result
+ */
+ protected Result robustSearch(Query query, Execution execution, T connection) {
+ Result result;
+ try {
+ result = search(query, execution, connection);
+ } catch (RuntimeException e) { //TODO: Exceptions should not be used to signal backend communication errors
+ log(LogLevel.WARNING, "An exception occurred while invoking backend searcher.", e);
+ result = new Result(query, ErrorMessage
+ .createBackendCommunicationError("Failed calling "
+ + connection + " in " + this + " for " + query
+ + ": " + Exceptions.toMessageString(e)));
+ }
+
+ if (result == null)
+ result = new Result(query, ErrorMessage
+ .createBackendCommunicationError("No result returned in "
+ + this + " from " + connection + " for " + query));
+
+ if (result.hits().getError() != null) {
+ log(LogLevel.FINE, "FAILED: ", query);
+ } else if (!result.isCached()) {
+ log(LogLevel.FINE, "WORKING: ", query);
+ } else {
+ log(LogLevel.FINE, "CACHE HIT: ", query);
+ }
+ return result;
+ }
+
+ /**
+ * Perform the search against the given connection. Return a result
+ * containing an error or throw an exception on failures.
+ */
+ protected abstract Result search(Query query, Execution execution, T connection);
+
+ public @Override
+ final void fill(Result result, String summaryClass, Execution execution) {
+ Query query = result.getQuery();
+ Hasher.NodeList<T> nodes = getHasher().getNodes();
+ int code = query.hashCode();
+
+ T connection = nodes.select(code, 0);
+ if (connection != null) {
+ if (timedOut(query)) {
+ result.hits().addError(
+ ErrorMessage.createTimeout(
+ "No time left to get summaries for "
+ + result));
+ } else {
+ // query.setTimeout(getNodeTimeout(query));
+ doFill(connection, result, summaryClass, execution);
+ }
+ } else {
+ result.hits().addError(
+ ErrorMessage.createNoBackendsInService("Could not fill '"
+ + result + "' in '" + this + "'"));
+ }
+ }
+
+ private void doFill(T connection, Result result, String summaryClass, Execution execution) {
+ try {
+ fill(result, summaryClass, execution, connection);
+ } catch (RuntimeException e) {
+ result.hits().addError(
+ ErrorMessage
+ .createBackendCommunicationError("Error filling "
+ + result + " from " + connection + ": "
+ + Exceptions.toMessageString(e)));
+ }
+ if (result.hits().getError() != null) {
+ log(LogLevel.FINE, "FAILED: ", result.getQuery());
+ } else if (!result.isCached()) {
+ log(LogLevel.FINE, "WORKING: ", result.getQuery());
+ } else {
+ log(LogLevel.FINE, "CACHE HIT: " + result.getQuery());
+ }
+ }
+
+ /**
+ * Perform the fill against the given connection. Add an error to the result
+ * or throw an exception on failures.
+ */
+ protected abstract void fill(Result result, String summaryClass,
+ Execution execution, T connection);
+
+ /** NodeManager method, called from ClusterMonitor */
+ public @Override
+ void working(T node) {
+ getHasher().add(node);
+ }
+
+ /** NodeManager method, called from ClusterMonitor */
+ public @Override
+ void failed(T node) {
+ getHasher().remove(node);
+ }
+
+ /**
+ * Returns the hasher used internally in this. Do not mutate this hasher
+ * while in use.
+ */
+ public Hasher<T> getHasher() {
+ return hasher;
+ }
+
+ /** Returns the monitor of these nodes */
+ public ClusterMonitor<T> getMonitor() {
+ return monitor;
+ }
+
+ /** Returns true if this query has timed out now */
+ protected boolean timedOut(Query query) {
+ long duration = query.getDurationTime();
+ return duration >= query.getTimeout();
+ }
+
+ protected void log(java.util.logging.Level level, Object... objects) {
+ if (!getLogger().isLoggable(level))
+ return;
+ StringBuilder sb = new StringBuilder();
+ for (Object object : objects) {
+ sb.append(object);
+ }
+ getLogger().log(level, sb.toString());
+ }
+
+ public @Override void deconstruct() {
+ super.deconstruct();
+ monitor.shutdown();
+ }
+
+ private class Pinger implements Callable<Pong> {
+
+ private T connection;
+
+ public Pinger(T connection) {
+ this.connection = connection;
+ }
+
+ public Pong call() {
+ Pong pong;
+ try {
+ pong = ping(new Ping(monitor.getConfiguration().getRequestTimeout()), connection);
+ } catch (RuntimeException e) {
+ pong = new Pong();
+ pong.addError(
+ ErrorMessage.createBackendCommunicationError(
+ "Exception when pinging "
+ + connection + ": "
+ + Exceptions.toMessageString(e)));
+ }
+ return pong;
+ }
+
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/Hasher.java b/container-search/src/main/java/com/yahoo/search/cluster/Hasher.java
new file mode 100644
index 00000000000..7ef71a7968d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/Hasher.java
@@ -0,0 +1,130 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+/**
+ * A hasher load balances between a set of nodes, represented by object ids.
+ *
+ * @author Arne B Fossaa
+ * @author bratseth
+ * @author Prashanth B. Bhat
+ */
+public class Hasher<T> {
+
+ public static class NodeFactor<T> {
+ private final T node;
+ /**
+ * The relative weight of the different nodes.
+ * Hashing are based on the proportions of the weights.
+ */
+ private final int load;
+ public NodeFactor(T node, int load) {
+ this.node = node;
+ this.load = load;
+ }
+ public final T getNode() { return node; }
+ public final int getLoad() { return load; }
+ }
+
+ public static class NodeList<T> {
+ private final NodeFactor<T>[] nodes;
+
+ private int totalLoadFactor;
+
+ public NodeList(NodeFactor<T>[] nodes) {
+ this.nodes = nodes;
+ totalLoadFactor = 0;
+ if(nodes != null) {
+ for(NodeFactor<T> node:nodes) {
+ totalLoadFactor += node.getLoad();
+ }
+ }
+ }
+
+ public int getNodeCount() {
+ return nodes.length;
+ }
+
+ public T select(int code, int trynum) {
+ if (totalLoadFactor <= 0) return null;
+
+ // Multiply by a prime number much bigger than the likely number of hosts
+ int hashValue=(Math.abs(code*76103)) % totalLoadFactor;
+ int sumLoad=0;
+ int targetNode=0;
+ for (targetNode=0; targetNode<nodes.length; targetNode++) {
+ sumLoad +=nodes[targetNode].getLoad();
+ if (sumLoad > hashValue)
+ break;
+ }
+ // Skip the ones we have tried before.
+ targetNode += trynum;
+ targetNode %= nodes.length;
+ return nodes[targetNode].getNode();
+ }
+
+ public boolean hasNode(T node) {
+ for(int i = 0;i<nodes.length;i++) {
+ if(node == nodes[i].getNode()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ }
+
+ private volatile NodeList<T> nodes;
+
+ @SuppressWarnings("unchecked")
+ public Hasher() {
+ this.nodes = new NodeList<T>(new NodeFactor[0]);
+ }
+
+ /** Adds a node with load factor 100 */
+ public void add(T node) {
+ add(node,100);
+ }
+
+ /**
+ * Adds a code with a load factor.
+ * The load factor is relative to the load of the other added nodes
+ * and determines how often this node will be selected compared
+ * to the other nodes
+ */
+ public synchronized void add(T node,int load) {
+ assert(nodes != null);
+ if(!nodes.hasNode(node)) {
+ NodeFactor<T>[] oldNodes = nodes.nodes;
+ @SuppressWarnings("unchecked")
+ NodeFactor<T>[] newNodes = (NodeFactor<T>[]) new NodeFactor[oldNodes.length+ 1];
+ System.arraycopy(oldNodes,0,newNodes,0,oldNodes.length);
+ newNodes[newNodes.length-1] = new NodeFactor<>(node, load);
+
+ //Atomic switch due to volatile
+ nodes = new NodeList<>(newNodes);
+ }
+ }
+
+ /** Removes a node */
+ public synchronized void remove(T node) {
+ if( nodes.hasNode(node)) {
+ NodeFactor<T>[] oldNodes = nodes.nodes;
+ @SuppressWarnings("unchecked")
+ NodeFactor<T>[] newNodes = (NodeFactor<T>[]) new NodeFactor[oldNodes.length - 1];
+ for (int i = 0, j = 0; i < oldNodes.length; i++) {
+ if (oldNodes[i].getNode() != node) {
+ newNodes[j++] = oldNodes[i];
+ }
+ }
+ // An atomic switch due to volatile.
+ nodes = new NodeList<>(newNodes);
+ }
+ }
+
+ /**
+ * Returns a list of nodes that are up.
+ */
+ public NodeList<T> getNodes() {
+ return nodes;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/MonitorConfiguration.java b/container-search/src/main/java/com/yahoo/search/cluster/MonitorConfiguration.java
new file mode 100644
index 00000000000..c68b60a743b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/MonitorConfiguration.java
@@ -0,0 +1,140 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+/**
+ * The configuration of a cluster monitor instance
+ *
+ * @author bratseth
+ */
+public class MonitorConfiguration {
+
+ /**
+ * The interval in ms between consecutive checks of the monitored
+ * nodes
+ */
+ private long checkInterval=1000;
+
+ /**
+ * The number of times a failed node must respond before getting
+ * traffic again
+ */
+ private int responseAfterFailLimit=3;
+
+ /**
+ * The number of ms a node is allowed to stay idle before it is
+ * pinged
+ */
+ private long idleLimit=3000;
+
+ /**
+ * The number of milliseconds to attempt to complete a request
+ * before giving up
+ */
+ private long requestTimeout = 5000;
+
+ /**
+ * The number of milliseconds a node is allowed to fail before we
+ * mark it as not working
+ */
+ private long failLimit=5000;
+
+ /**
+ * The number of times a node is allowed to fail in one hour
+ * before it is quarantined for an hour
+ */
+ private int failQuarantineLimit=3;
+
+ /**
+ * The number of ms to quarantine an unstable node
+ */
+ private long quarantineTime=1000*60*60;
+
+ /**
+ * Sets the interval between each ping of idle or failing nodes
+ * Default is 1000ms
+ */
+ public void setCheckInterval(long intervalMs) {
+ this.checkInterval=intervalMs;
+ }
+
+ /**
+ * Returns the interval between each ping of idle or failing nodes
+ * Default is 1000ms
+ */
+ public long getCheckInterval() {
+ return checkInterval;
+ }
+
+ /**
+ * Sets the number of times a failed node must respond before it is put
+ * back in service. Default is 3.
+ */
+ public void setResponseAfterFailLimit(int responseAfterFailLimit) {
+ this.responseAfterFailLimit=responseAfterFailLimit;
+ }
+
+ /**
+ * Sets the number of ms a node (failing or working) is allowed to
+ * stay idle before it is pinged. Default is 3000
+ */
+ public void setIdleLimit(int idleLimit) {
+ this.idleLimit=idleLimit;
+ }
+
+ /**
+ * Gets the number of ms a node (failing or working)
+ * is allowed to stay idle before it is pinged. Default is 3000
+ */
+ public long getIdleLimit() {
+ return idleLimit;
+ }
+
+ /**
+ * Returns the number of milliseconds to attempt to service a request
+ * (at different nodes) before giving up. Default is 5000 ms.
+ */
+ public long getRequestTimeout() { return requestTimeout; }
+
+ /**
+ * Sets the number of milliseconds a node is allowed to fail before we
+ * mark it as not working
+ */
+ public void setFailLimit(long failLimit) { this.failLimit=failLimit; }
+
+ /**
+ * Returns the number of milliseconds a node is allowed to fail before we
+ * mark it as not working
+ */
+ public long getFailLimit() { return failLimit; }
+
+ /**
+ * The number of times a node must fail in one hour to be placed
+ * in quarantine. Once in quarantine it won't be put back in
+ * productuion before quarantineTime has expired even if it is
+ * working. Default is 3
+ */
+ public void setFailQuarantineLimit(int failQuarantineLimit) {
+ this.failQuarantineLimit=failQuarantineLimit;
+ }
+
+ /**
+ * The number of ms an unstable node is quarantined. Default is
+ * 100*60*60
+ */
+ public void setQuarantineTime(long quarantineTime) {
+ this.quarantineTime=quarantineTime;
+ }
+
+ public String toString() {
+ return "monitor configuration [" +
+ "checkInterval: " + checkInterval +
+ " responseAfterFailLimit: " + responseAfterFailLimit +
+ " idleLimit: " + idleLimit +
+ " requestTimeout " + requestTimeout +
+ " feilLimit " + failLimit +
+ " failQuerantineLimit " + failQuarantineLimit +
+ " quarantineTime " + quarantineTime +
+ "]";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/NodeManager.java b/container-search/src/main/java/com/yahoo/search/cluster/NodeManager.java
new file mode 100644
index 00000000000..7071867c8c7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/NodeManager.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+import java.util.concurrent.Executor;
+
+/**
+ * Must be implemented by a node collection which wants
+ * it's node state monitored by a ClusterMonitor
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon S Bratseth</a>
+ */
+public interface NodeManager<T> {
+
+ /** Called when a failed node is working (ready for production) again */
+ public void working(T node);
+
+ /** Called when a working node fails */
+ public void failed(T node);
+
+ /** Called when a node should be pinged */
+ public void ping(T node, Executor executor);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/PingableSearcher.java b/container-search/src/main/java/com/yahoo/search/cluster/PingableSearcher.java
new file mode 100644
index 00000000000..486473eba8d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/PingableSearcher.java
@@ -0,0 +1,29 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.prelude.Ping;
+import com.yahoo.prelude.Pong;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * A searcher to which we can send a ping to probe if it is alive
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class PingableSearcher extends Searcher {
+
+ public PingableSearcher() {
+ }
+
+ public PingableSearcher(ComponentId id) {
+ super(id);
+ }
+
+ /** Send a ping request downwards to probe if this searcher chain is in functioning order */
+ public Pong ping(Ping ping, Execution execution) {
+ return execution.ping(ping);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/TrafficNodeMonitor.java b/container-search/src/main/java/com/yahoo/search/cluster/TrafficNodeMonitor.java
new file mode 100644
index 00000000000..6464f0101be
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/TrafficNodeMonitor.java
@@ -0,0 +1,93 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.cluster;
+
+import com.yahoo.search.result.ErrorMessage;
+
+
+/**
+ * This node monitor is responsible for maintaining the state of a monitored node.
+ * It has the following properties:
+ * <ul>
+ * <li>A node is taken out of operation if it gives no response in 10 s</li>
+ * <li>A node is put back in operation when it responds correctly again
+ * </ul>
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class TrafficNodeMonitor<T> extends BaseNodeMonitor<T> {
+ /**
+ * Creates a new node monitor for a node
+ */
+ public TrafficNodeMonitor(T node,MonitorConfiguration configuration,boolean internal) {
+ super(internal);
+ this.node=node;
+ this.configuration=configuration;
+ }
+
+ /** Whether or not this has ever responded successfully */
+ private boolean atStartUp = true;
+
+ public T getNode() { return node; }
+
+ /**
+ * Called when this node fails.
+ *
+ * @param error A container which should contain a short description
+ */
+ @Override
+ public void failed(ErrorMessage error) {
+ respondedAt=now();
+
+ switch (error.getCode()) {
+ // TODO: Remove hard coded error messages.
+ // Refer to docs/errormessages
+ case 10:
+ case 11:
+ // Only count not being able to talk to backend at all
+ // as errors we care about
+ if ((respondedAt-succeededAt) > 10000) {
+ setWorking(false,"Not working for 10 s: " + error.toString());
+ }
+ break;
+ default:
+ succeededAt = respondedAt;
+ break;
+ }
+ }
+
+ /**
+ * Called when a response is received from this node.
+ */
+ public void responded() {
+ respondedAt=now();
+ succeededAt=respondedAt;
+ atStartUp = false;
+
+ if (!isWorking) {
+ setWorking(true,"Responds correctly");
+ }
+ }
+
+ /** Thread-safely changes the state of this node if required */
+ protected synchronized void setWorking(boolean working,String explanation) {
+ if (this.isWorking==working) return; // Old news
+
+ if (explanation==null) {
+ explanation="";
+ } else {
+ explanation=": " + explanation;
+ }
+
+ if (working) {
+ log.info("Putting " + node + " in service" + explanation);
+ }
+ else {
+ if (!atStartUp || !isInternal())
+ log.warning("Taking " + node + " out of service" + explanation);
+ failedAt=now();
+ }
+
+ this.isWorking=working;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/cluster/package-info.java b/container-search/src/main/java/com/yahoo/search/cluster/package-info.java
new file mode 100644
index 00000000000..b470d8c8150
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/cluster/package-info.java
@@ -0,0 +1,12 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Standard searchers to compose in <i>source</i> search chains (those containing searchers specific for one source and
+ * which ends with a call to some provider) which calls a cluster of provider nodes. These searchers provides hashing
+ * and failover of the provider nodes.
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.cluster;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/config/dispatchprototype/package-info.java b/container-search/src/main/java/com/yahoo/search/config/dispatchprototype/package-info.java
new file mode 100644
index 00000000000..2a7b4f96aa8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/config/dispatchprototype/package-info.java
@@ -0,0 +1,9 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Package for dispatchprototype config.
+ * @author tonytv
+ */
+@ExportPackage
+package com.yahoo.search.config.dispatchprototype;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/config/package-info.java b/container-search/src/main/java/com/yahoo/search/config/package-info.java
new file mode 100644
index 00000000000..84eb92be0ea
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/config/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.search.config;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/debug/BackendStatistics.java b/container-search/src/main/java/com/yahoo/search/debug/BackendStatistics.java
new file mode 100644
index 00000000000..8086048890f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/BackendStatistics.java
@@ -0,0 +1,60 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import static com.yahoo.search.debug.SearcherUtils.clusterSearchers;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang.ArrayUtils;
+
+import com.yahoo.fs4.mplex.Backend;
+import com.yahoo.jrt.Int32Array;
+import com.yahoo.jrt.Request;
+import com.yahoo.jrt.StringArray;
+import com.yahoo.jrt.Value;
+import com.yahoo.jrt.Values;
+import com.yahoo.prelude.cluster.ClusterSearcher;
+import com.yahoo.yolean.Exceptions;
+
+/**
+ * @author tonytv
+ */
+public class BackendStatistics implements DebugMethodHandler {
+ public JrtMethodSignature getSignature() {
+ String returnTypes = "" + (char)Value.STRING_ARRAY + (char)Value.INT32_ARRAY + (char)Value.INT32_ARRAY;
+ String parametersTypes = "" + (char)Value.STRING;
+
+ return new JrtMethodSignature(returnTypes, parametersTypes);
+ }
+
+ public void invoke(Request request) {
+ try {
+ Collection<ClusterSearcher> searchers = clusterSearchers(request);
+ List<String> backendIdentificators = new ArrayList<>();
+ List<Integer> activeConnections = new ArrayList<>();
+ List<Integer> totalConnections = new ArrayList<>();
+
+ for (ClusterSearcher searcher : searchers) {
+ for (Map.Entry<String,Backend.BackendStatistics> statistics : searcher.getBackendStatistics().entrySet()) {
+ backendIdentificators.add(statistics.getKey());
+ activeConnections.add(statistics.getValue().activeConnections);
+ totalConnections.add(statistics.getValue().totalConnections());
+ }
+ }
+ Values returnValues = request.returnValues();
+ returnValues.add(new StringArray(backendIdentificators.toArray(new String[0])));
+ addInt32Array(returnValues, activeConnections);
+ addInt32Array(returnValues, totalConnections);
+
+ } catch (Exception e) {
+ request.setError(1000, Exceptions.toMessageString(e));
+ }
+ }
+
+ private void addInt32Array(Values returnValues, List<Integer> ints) {
+ returnValues.add(new Int32Array(ArrayUtils.toPrimitive(ints.toArray(new Integer[0]))));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/DebugMethodHandler.java b/container-search/src/main/java/com/yahoo/search/debug/DebugMethodHandler.java
new file mode 100644
index 00000000000..55f36b9670e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/DebugMethodHandler.java
@@ -0,0 +1,13 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import com.yahoo.jrt.MethodHandler;
+
+/**
+ * A method handler that can describe its signature.
+ *
+ * @author tonytv
+ */
+interface DebugMethodHandler extends MethodHandler {
+ JrtMethodSignature getSignature();
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/DebugRpcAdaptor.java b/container-search/src/main/java/com/yahoo/search/debug/DebugRpcAdaptor.java
new file mode 100644
index 00000000000..2309f23985c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/DebugRpcAdaptor.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import com.yahoo.container.osgi.AbstractRpcAdaptor;
+import com.yahoo.jrt.Method;
+import com.yahoo.jrt.Supervisor;
+import com.yahoo.fs4.PacketDumper.PacketType;
+
+/**
+ * Handles rpc calls for retrieving debug information.
+ *
+ * @author tonytv
+ */
+public final class DebugRpcAdaptor extends AbstractRpcAdaptor {
+ private static final String debugPrefix = "debug.";
+
+ public void bindCommands(Supervisor supervisor) {
+ addTraceMethod(supervisor, "query", PacketType.query);
+ addTraceMethod(supervisor, "result", PacketType.result);
+ addMethod(supervisor, "output-search-chain", new OutputSearchChain());
+ addMethod(supervisor, "backend-statistics", new BackendStatistics());
+ }
+
+ private void addTraceMethod(Supervisor supervisor, String name, PacketType packetType) {
+ addMethod(supervisor, constructTraceMethodName(name), new TracePackets(packetType));
+ }
+
+ private void addMethod(Supervisor supervisor, String name, DebugMethodHandler handler) {
+ JrtMethodSignature typeStrings = handler.getSignature();
+ supervisor.addMethod(
+ new Method(debugPrefix + name,
+ typeStrings.parametersTypes,
+ typeStrings.returnTypes,
+ handler));
+
+ }
+
+ //example: debug.dump-query-packets
+ private String constructTraceMethodName(String name) {
+ return debugPrefix + "dump-" + name + "-packets";
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/IndentStringBuilder.java b/container-search/src/main/java/com/yahoo/search/debug/IndentStringBuilder.java
new file mode 100644
index 00000000000..acb9be8294f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/IndentStringBuilder.java
@@ -0,0 +1,102 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import java.io.Serializable;
+
+/**
+ * A StringBuilder that also handles indentation for append operations.
+ * @author tonytv
+ */
+@SuppressWarnings("serial")
+final class IndentStringBuilder implements Serializable, Appendable, CharSequence {
+ private final StringBuilder builder = new StringBuilder();
+ private final String singleIndentation;
+
+ private int level = 0;
+ private boolean newline = true;
+
+ private void appendIndentation() {
+ if (newline) {
+ for (int i=0; i<level; i++) {
+ builder.append(singleIndentation);
+ }
+ }
+ newline = false;
+ }
+
+ public IndentStringBuilder(String singleIndentation) {
+ this.singleIndentation = singleIndentation;
+ }
+
+ public IndentStringBuilder() {
+ this(" ");
+ }
+
+ public void resetIndentLevel(int level) {
+ this.level = level;
+ }
+
+ //returns the indent level before indenting.
+ public int newlineAndIndent() {
+ newline();
+ return indent();
+ }
+
+ //returns the indent level before indenting.
+ public int indent() {
+ return level++;
+ }
+
+ public IndentStringBuilder newline() {
+ newline = true;
+ builder.append('\n');
+ return this;
+ }
+
+ public IndentStringBuilder append(Object o) {
+ appendIndentation();
+ builder.append(o);
+ return this;
+ }
+
+ public IndentStringBuilder append(String s) {
+ appendIndentation();
+ builder.append(s);
+ return this;
+ }
+
+ public IndentStringBuilder append(CharSequence charSequence) {
+ appendIndentation();
+ builder.append(charSequence);
+ return this;
+ }
+
+ public IndentStringBuilder append(CharSequence charSequence, int i, int i1) {
+ appendIndentation();
+ builder.append(charSequence, i, i1);
+ return this;
+ }
+
+ public IndentStringBuilder append(char c) {
+ appendIndentation();
+ builder.append(c);
+ return this;
+ }
+
+ public String toString() {
+ return builder.toString();
+ }
+
+ public int length() {
+ return builder.length();
+ }
+
+ public char charAt(int i) {
+ return builder.charAt(i);
+ }
+
+ public CharSequence subSequence(int i, int i1) {
+ return builder.subSequence(i, i1);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/JrtMethodSignature.java b/container-search/src/main/java/com/yahoo/search/debug/JrtMethodSignature.java
new file mode 100644
index 00000000000..0383360487f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/JrtMethodSignature.java
@@ -0,0 +1,17 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+/**
+ * Represents the signatures of a jrt method.
+ *
+ * @author tonytv
+ */
+final class JrtMethodSignature {
+ final String returnTypes;
+ final String parametersTypes;
+
+ JrtMethodSignature(String returnTypes, String parametersTypes) {
+ this.returnTypes = returnTypes;
+ this.parametersTypes = parametersTypes;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/OutputSearchChain.java b/container-search/src/main/java/com/yahoo/search/debug/OutputSearchChain.java
new file mode 100644
index 00000000000..4413ea462c8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/OutputSearchChain.java
@@ -0,0 +1,59 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import static com.yahoo.protect.Validator.ensureNotNull;
+
+import com.yahoo.jrt.Request;
+import com.yahoo.jrt.StringValue;
+import com.yahoo.jrt.Value;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.handler.SearchHandler;
+import com.yahoo.search.searchchain.SearchChain;
+import com.yahoo.search.searchchain.SearchChainRegistry;
+
+/**
+ * Outputs a human readable representation of a given search chain.
+ *
+ * @author tonytv
+ */
+final class OutputSearchChain implements DebugMethodHandler {
+ private String getSearchChainName(Request request) {
+ final int numParameters = request.parameters().size();
+
+ if (numParameters == 0)
+ return SearchHandler.defaultSearchChainName;
+ else if (numParameters == 1)
+ return request.parameters().get(0).asString();
+ else
+ throw new RuntimeException("Too many parameters given.");
+ }
+
+ private SearchChain getSearchChain(SearchChainRegistry registry, String searchChainName) {
+ SearchChain searchChain = registry.getComponent(searchChainName);
+ ensureNotNull("There is no search chain named '" + searchChainName + "'", searchChain);
+ return searchChain;
+ }
+
+ public JrtMethodSignature getSignature() {
+ String returnTypes = "" + (char)Value.STRING;
+ String parametersTypes = "*"; //optional string
+ return new JrtMethodSignature(returnTypes, parametersTypes);
+ }
+
+ public void invoke(Request request) {
+ try {
+ SearchHandler searchHandler = SearcherUtils.getSearchHandler();
+ SearchChainRegistry searchChainRegistry = searchHandler.getSearchChainRegistry();
+ SearchChain searchChain = getSearchChain(searchChainRegistry,
+ getSearchChainName(request));
+
+ SearchChainTextRepresentation textRepresentation = new SearchChainTextRepresentation(searchChain, searchChainRegistry);
+ request.returnValues().add(new StringValue(textRepresentation.toString()));
+ } catch (Exception e) {
+ request.setError(1000, Exceptions.toMessageString(e));
+ }
+ }
+
+
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/debug/SearchChainTextRepresentation.java b/container-search/src/main/java/com/yahoo/search/debug/SearchChainTextRepresentation.java
new file mode 100644
index 00000000000..2e9da99f85b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/SearchChainTextRepresentation.java
@@ -0,0 +1,88 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import com.yahoo.component.chain.Chain;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.ForkingSearcher;
+import com.yahoo.search.searchchain.SearchChain;
+import com.yahoo.search.searchchain.SearchChainRegistry;
+
+import java.util.Collection;
+
+/**
+ * Text representation of a given search chain intended for debugging purposes.
+ *
+ * @author tonytv
+ */
+public class SearchChainTextRepresentation {
+
+ private final SearchChainRegistry searchChainRegistry;
+
+ private static class Block {
+ private static final String openBlock = " {";
+ private static final char closeBlock = '}';
+ private final IndentStringBuilder str;
+ private final int level;
+
+ Block(IndentStringBuilder str) {
+ this.str = str;
+ level = str.append(openBlock).newlineAndIndent();
+ }
+
+ void close() {
+ str.resetIndentLevel(level);
+ str.append(closeBlock).newline();
+ }
+ }
+
+ private final String textRepresentation;
+
+ private void outputChain(IndentStringBuilder str, Chain<Searcher> chain) {
+ if (chain == null) {
+ str.append(" [Unresolved Searchchain]");
+ } else {
+ str.append(chain.getId()).append(" [Searchchain] ");
+ Block block = new Block(str);
+
+ for (Searcher searcher : chain.components())
+ outputSearcher(str, searcher);
+
+ block.close();
+ }
+ }
+
+ private void outputSearcher(IndentStringBuilder str, Searcher searcher) {
+ str.append(searcher.getId()).append(" [Searcher]");
+ if ( ! (searcher instanceof ForkingSearcher) ) {
+ str.newline();
+ return;
+ }
+ Collection<ForkingSearcher.CommentedSearchChain> chains =
+ ((ForkingSearcher)searcher).getSearchChainsForwarded(searchChainRegistry);
+ if (chains.isEmpty()) {
+ str.newline();
+ return;
+ }
+ Block block = new Block(str);
+ for (ForkingSearcher.CommentedSearchChain chain : chains) {
+ if (chain.comment != null)
+ str.append(chain.comment).newline();
+ outputChain(str, chain.searchChain);
+ }
+ block.close();
+ }
+
+ @Override
+ public String toString() {
+ return textRepresentation;
+ }
+
+ public SearchChainTextRepresentation(SearchChain searchChain, SearchChainRegistry searchChainRegistry) {
+ this.searchChainRegistry = searchChainRegistry;
+
+ IndentStringBuilder stringBuilder = new IndentStringBuilder();
+ outputChain(stringBuilder, searchChain);
+ textRepresentation = stringBuilder.toString();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/SearcherUtils.java b/container-search/src/main/java/com/yahoo/search/debug/SearcherUtils.java
new file mode 100644
index 00000000000..1633196a585
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/SearcherUtils.java
@@ -0,0 +1,67 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import static com.yahoo.protect.Validator.ensureNotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import com.yahoo.component.provider.ComponentRegistry;
+import org.apache.commons.collections.CollectionUtils;
+
+import com.yahoo.container.Container;
+import com.yahoo.jrt.Request;
+import com.yahoo.prelude.cluster.ClusterSearcher;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.handler.SearchHandler;
+import com.yahoo.search.searchchain.SearchChainRegistry;
+
+/**
+ * Utility functions for searchers and search chains.
+ *
+ * @author tonytv
+ */
+final class SearcherUtils {
+ private static Collection<Searcher> allSearchers() {
+ SearchChainRegistry searchChainRegistry = getSearchHandler().getSearchChainRegistry();
+ ComponentRegistry<Searcher> searcherRegistry = searchChainRegistry.getSearcherRegistry();
+ return searcherRegistry.allComponents();
+ }
+
+ private static Collection<ClusterSearcher> allClusterSearchers() {
+ return filter(allSearchers(), ClusterSearcher.class);
+ }
+
+ private static <T> Collection<T> filter(Collection<?> collection, Class<T> classToMatch) {
+ List<T> filtered = new ArrayList<>();
+ for (Object candidate : collection) {
+ if (classToMatch.isInstance(candidate))
+ filtered.add(classToMatch.cast(candidate));
+ }
+ return filtered;
+ }
+
+ public static Collection<ClusterSearcher> clusterSearchers(final String clusterName) {
+ Collection<ClusterSearcher> searchers = allClusterSearchers();
+ CollectionUtils.filter(searchers,
+ o -> clusterName.equalsIgnoreCase(((ClusterSearcher)o).getClusterModelName()));
+ return searchers;
+ }
+
+ //Return value is never null
+ static SearchHandler getSearchHandler() {
+ SearchHandler searchHandler = (SearchHandler) Container.get().getRequestHandlerRegistry().getComponent("com.yahoo.search.handler.SearchHandler");
+ ensureNotNull("The standard search handler is not available.", searchHandler);
+ return searchHandler;
+ }
+
+ //Retrieve all the cluster searchers as specified by the first parameter of the request.
+ static Collection<ClusterSearcher> clusterSearchers(Request request) {
+ String clusterName = request.parameters().get(0).asString();
+ Collection<ClusterSearcher> searchers = clusterSearchers(clusterName);
+ if (searchers.isEmpty())
+ throw new RuntimeException("No cluster named " + clusterName);
+ return searchers;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/debug/TracePackets.java b/container-search/src/main/java/com/yahoo/search/debug/TracePackets.java
new file mode 100644
index 00000000000..de71b2e3f26
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/debug/TracePackets.java
@@ -0,0 +1,44 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.debug;
+
+import static com.yahoo.search.debug.SearcherUtils.clusterSearchers;
+
+import java.util.Collection;
+
+import com.yahoo.jrt.Request;
+import com.yahoo.jrt.Value;
+import com.yahoo.prelude.cluster.ClusterSearcher;
+import com.yahoo.fs4.PacketDumper;
+import com.yahoo.yolean.Exceptions;
+
+/**
+ * Rpc method for enabling packet dumping for a specific packet type.
+ *
+ * @author tonytv
+ */
+final class TracePackets implements DebugMethodHandler {
+ private final PacketDumper.PacketType packetType;
+
+ public void invoke(Request request) {
+ try {
+ Collection<ClusterSearcher> searchers = clusterSearchers(request);
+ boolean on = request.parameters().get(1).asInt8() != 0;
+
+ for (ClusterSearcher searcher : searchers)
+ searcher.dumpPackets(packetType, on);
+
+ } catch (Exception e) {
+ request.setError(1000, Exceptions.toMessageString(e));
+ }
+ }
+
+ TracePackets(PacketDumper.PacketType packetType) {
+ this.packetType = packetType;
+ }
+
+ public JrtMethodSignature getSignature() {
+ String returnTypes = "";
+ String parametersTypes = "" + (char)Value.STRING + (char)Value.INT8;
+ return new JrtMethodSignature(returnTypes, parametersTypes);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/dispatch/Client.java b/container-search/src/main/java/com/yahoo/search/dispatch/Client.java
new file mode 100644
index 00000000000..19d6a0c523b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/dispatch/Client.java
@@ -0,0 +1,90 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.dispatch;
+
+import com.yahoo.compress.CompressionType;
+import com.yahoo.prelude.fastsearch.FastHit;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * A dispatch client.
+ *
+ * @author bratseth
+ */
+interface Client {
+
+ void getDocsums(List<FastHit> hits, NodeConnection node, CompressionType compression,
+ int uncompressedLength, byte[] compressedSlime, Dispatcher.GetDocsumsResponseReceiver responseReceiver,
+ double timeoutSeconds);
+
+ /** Creates a connection to a particular node in this */
+ NodeConnection createConnection(String hostname, int port);
+
+ class GetDocsumsResponseOrError {
+
+ // One of these will be non empty and the other not
+ private Optional<GetDocsumsResponse> response;
+ private Optional<String> error;
+
+ public static GetDocsumsResponseOrError fromResponse(GetDocsumsResponse response) {
+ return new GetDocsumsResponseOrError(Optional.of(response), Optional.empty());
+ }
+
+ public static GetDocsumsResponseOrError fromError(String error) {
+ return new GetDocsumsResponseOrError(Optional.empty(), Optional.of(error));
+ }
+
+ private GetDocsumsResponseOrError(Optional<GetDocsumsResponse> response, Optional<String> error) {
+ this.response = response;
+ this.error = error;
+ }
+
+ /** Returns the response, or empty if there is an error */
+ public Optional<GetDocsumsResponse> response() { return response; }
+
+ /** Returns the error or empty if there is a response */
+ public Optional<String> error() { return error; }
+
+ }
+
+ class GetDocsumsResponse {
+
+ private final byte compression;
+ private final int uncompressedSize;
+ private final byte[] compressedSlimeBytes;
+ private final List<FastHit> hitsContext;
+
+ public GetDocsumsResponse(byte compression, int uncompressedSize, byte[] compressedSlimeBytes, List<FastHit> hitsContext) {
+ this.compression = compression;
+ this.uncompressedSize = uncompressedSize;
+ this.compressedSlimeBytes = compressedSlimeBytes;
+ this.hitsContext = hitsContext;
+ }
+
+ public byte compression() {
+ return compression;
+ }
+
+ public int uncompressedSize() {
+ return uncompressedSize;
+ }
+
+ public byte[] compressedSlimeBytes() {
+ return compressedSlimeBytes;
+ }
+
+ public List<FastHit> hitsContext() {
+ return hitsContext;
+ }
+
+ }
+
+ interface NodeConnection {
+
+ /** Closes this connection */
+ void close();
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/dispatch/Dispatcher.java b/container-search/src/main/java/com/yahoo/search/dispatch/Dispatcher.java
new file mode 100644
index 00000000000..e4d1fb0b1d5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/dispatch/Dispatcher.java
@@ -0,0 +1,228 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.dispatch;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.inject.Inject;
+import com.yahoo.collections.ListMap;
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.compress.CompressionType;
+import com.yahoo.compress.Compressor;
+import com.yahoo.data.access.slime.SlimeAdapter;
+import com.yahoo.prelude.fastsearch.FastHit;
+import com.yahoo.prelude.fastsearch.TimeoutException;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.slime.BinaryFormat;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.data.access.Inspector;
+import com.yahoo.vespa.config.search.DispatchConfig;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A dispatcher communicates with search nodes to (in the future) perform queries and (now) fill hits.
+ * This class is multithread safe.
+ *
+ * @author bratseth
+ */
+public class Dispatcher extends AbstractComponent {
+
+ private final static Logger log = Logger.getLogger(Dispatcher.class.getName());
+ private final Client client;
+
+ /** Connections to the search nodes this talks to, indexed by node id ("partid") */
+ private final ImmutableMap<Integer, Client.NodeConnection> nodes;
+
+ private final Compressor compressor = new Compressor();
+
+ @Inject
+ public Dispatcher(DispatchConfig dispatchConfig) {
+ this.client = new RpcClient();
+ ImmutableMap.Builder<Integer, Client.NodeConnection> nodesBuilder = new ImmutableMap.Builder<>();
+ for (DispatchConfig.Node node : dispatchConfig.node()) {
+ nodesBuilder.put(node.key(), client.createConnection(node.host(), node.port()));
+ }
+ nodes = nodesBuilder.build();
+ }
+
+ /** For testing */
+ public Dispatcher(Map<Integer, Client.NodeConnection> nodeConnections, Client client) {
+ this.nodes = ImmutableMap.copyOf(nodeConnections);
+ this.client = client;
+ }
+
+ /** Fills the given summary class by sending RPC requests to the right search nodes */
+ public void fill(Result result, String summaryClass, CompressionType compression) {
+ try {
+ ListMap<Integer, FastHit> hitsByNode = hitsByNode(result);
+
+ GetDocsumsResponseReceiver responseReceiver = new GetDocsumsResponseReceiver(hitsByNode.size(), compressor, result);
+ for (Map.Entry<Integer, List<FastHit>> nodeHits : hitsByNode.entrySet()) {
+ sendGetDocsumsRequest(nodeHits.getKey(), nodeHits.getValue(), summaryClass, compression, result, responseReceiver);
+ }
+ responseReceiver.processResponses(result.getQuery());
+ }
+ catch (TimeoutException e) {
+ result.hits().addError(ErrorMessage.createTimeout("Summary data is incomplete: " + e.getMessage()));
+ }
+ }
+
+ /** Return a map of hits by their search node (partition) id */
+ private ListMap<Integer, FastHit> hitsByNode(Result result) {
+ ListMap<Integer, FastHit> hitsByPartition = new ListMap<>();
+ for (Iterator<Hit> i = result.hits().deepIterator() ; i.hasNext(); ) {
+ Hit h = i.next();
+ if ( ! (h instanceof FastHit)) continue;
+ FastHit hit = (FastHit)h;
+
+ hitsByPartition.put(hit.getDistributionKey(), hit);
+ }
+ return hitsByPartition;
+ }
+
+ /** Send a getDocsums request to a node. Responses will be added to the given receiver. */
+ private void sendGetDocsumsRequest(int nodeId, List<FastHit> hits, String summaryClass,
+ CompressionType compression,
+ Result result, GetDocsumsResponseReceiver responseReceiver) {
+ Client.NodeConnection node = nodes.get(nodeId);
+ if (node == null) {
+ result.hits().addError(ErrorMessage.createEmptyDocsums("Could not fill hits from unknown node " + nodeId));
+ log.warning("Got hits with partid " + nodeId + ", which is not included in the current dispatch config");
+ return;
+ }
+
+ byte[] serializedSlime = BinaryFormat.encode(toSlime(summaryClass, hits));
+ double timeoutSeconds = ((double)result.getQuery().getTimeLeft()-3.0)/1000.0;
+ Compressor.Compression compressionResult = compressor.compress(compression, serializedSlime);
+ client.getDocsums(hits, node, compressionResult.type(),
+ serializedSlime.length, compressionResult.data(), responseReceiver, timeoutSeconds);
+ }
+
+ public Slime toSlime(String summaryClass, List<FastHit> hits) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+ if (summaryClass != null) {
+ root.setString("class", summaryClass);
+ }
+ Cursor gids = root.setArray("gids");
+ for (FastHit hit : hits) {
+ gids.addData(hit.getGlobalId().getRawId());
+ }
+ return slime;
+ }
+
+ @Override
+ public void deconstruct() {
+ for (Client.NodeConnection nodeConnection : nodes.values())
+ nodeConnection.close();
+ }
+
+ /** Receiver of the responses to a set of getDocsums requests */
+ public static class GetDocsumsResponseReceiver {
+
+ private final BlockingQueue<Client.GetDocsumsResponseOrError> responses;
+ private final Compressor compressor;
+ private final Result result;
+
+ /** Whether we have already logged/notified about an error - to avoid spamming */
+ private boolean hasReportedError = false;
+
+ /** The number of responses we should receive (and process) before this is complete */
+ private int outstandingResponses;
+
+ public GetDocsumsResponseReceiver(int requestCount, Compressor compressor, Result result) {
+ this.compressor = compressor;
+ responses = new LinkedBlockingQueue<>(requestCount);
+ outstandingResponses = requestCount;
+ this.result = result;
+ }
+
+ /** Called by a thread belonging to the client when a valid response becomes available */
+ public void receive(Client.GetDocsumsResponseOrError response) {
+ responses.add(response);
+ }
+
+ private void throwTimeout() throws TimeoutException {
+ throw new TimeoutException("Timed out waiting for summary data. " + outstandingResponses + " responses outstanding.");
+ }
+
+ /**
+ * Call this from the dispatcher thread to initiate and complete processing of responses.
+ * This will block until all responses are available and processed, or to timeout.
+ */
+ public void processResponses(Query query) throws TimeoutException {
+ try {
+ while (outstandingResponses > 0) {
+ long timeLeftMs = query.getTimeLeft();
+ if (timeLeftMs <= 0) {
+ throwTimeout();
+ }
+ Client.GetDocsumsResponseOrError response = responses.poll(timeLeftMs, TimeUnit.MILLISECONDS);
+ if (response == null)
+ throwTimeout();
+ processResponse(response);
+ outstandingResponses--;
+ }
+ }
+ catch (InterruptedException e) {
+ // TODO: Add error
+ }
+ }
+
+ private void processResponse(Client.GetDocsumsResponseOrError responseOrError) {
+ if (responseOrError.error().isPresent()) {
+ if (hasReportedError) return;
+ String error = responseOrError.error().get();
+ result.hits().addError(ErrorMessage.createBackendCommunicationError(error));
+ log.log(Level.WARNING, "Error fetching summary data: "+ error);
+ }
+ else {
+ Client.GetDocsumsResponse response = responseOrError.response().get();
+ CompressionType compression = CompressionType.valueOf(response.compression());
+ byte[] slimeBytes = compressor.decompress(response.compressedSlimeBytes(), compression, response.uncompressedSize());
+ fill(response.hitsContext(), slimeBytes);
+ }
+ }
+
+ private void fill(List<FastHit> hits, byte[] slimeBytes) {
+ Inspector summaries = new SlimeAdapter(BinaryFormat.decode(slimeBytes).get().field("docsums"));
+ if ( ! summaries.valid())
+ throw new IllegalArgumentException("Expected a Slime root object containing a 'docsums' field");
+ for (int i = 0; i < hits.size(); i++) {
+ fill(hits.get(i), summaries.entry(i).field("docsum"));
+ }
+ }
+
+ private void fill(FastHit hit, Inspector summary) {
+ summary.traverse((String name, Inspector value) -> {
+ hit.setField(name, nativeTypeOf(value));
+ });
+ }
+
+ private Object nativeTypeOf(Inspector inspector) {
+ switch (inspector.type()) {
+ case ARRAY: return inspector;
+ case OBJECT: return inspector;
+ case BOOL: return inspector.asBool();
+ case DATA: return inspector.asData();
+ case DOUBLE: return inspector.asDouble();
+ case LONG: return inspector.asLong();
+ case STRING: return inspector.asString(); // TODO: Keep as utf8
+ case EMPTY : return null;
+ default: throw new IllegalArgumentException("Unexpected Slime type " + inspector.type());
+ }
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/dispatch/RpcClient.java b/container-search/src/main/java/com/yahoo/search/dispatch/RpcClient.java
new file mode 100644
index 00000000000..0305b06e92f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/dispatch/RpcClient.java
@@ -0,0 +1,128 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.dispatch;
+
+import com.yahoo.compress.CompressionType;
+import com.yahoo.jrt.DataValue;
+import com.yahoo.jrt.Int32Value;
+import com.yahoo.jrt.Int8Value;
+import com.yahoo.jrt.Request;
+import com.yahoo.jrt.RequestWaiter;
+import com.yahoo.jrt.Spec;
+import com.yahoo.jrt.Supervisor;
+import com.yahoo.jrt.Target;
+import com.yahoo.jrt.Transport;
+import com.yahoo.jrt.Values;
+import com.yahoo.prelude.fastsearch.FastHit;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * A client which uses rpc request to search nodes to implement the Client API.
+ *
+ * @author bratseth
+ */
+class RpcClient implements Client {
+
+ private final Supervisor supervisor = new Supervisor(new Transport());
+
+ @Override
+ public NodeConnection createConnection(String hostname, int port) {
+ return new RpcNodeConnection(hostname, port, supervisor);
+ }
+
+ @Override
+ public void getDocsums(List<FastHit> hits, NodeConnection node, CompressionType compression, int uncompressedLength,
+ byte[] compressedSlime, Dispatcher.GetDocsumsResponseReceiver responseReceiver, double timeoutSeconds) {
+ Request request = new Request("proton.getDocsums");
+ request.parameters().add(new Int8Value(compression.getCode()));
+ request.parameters().add(new Int32Value(uncompressedLength));
+ request.parameters().add(new DataValue(compressedSlime));
+
+ request.setContext(hits);
+ RpcNodeConnection rpcNode = ((RpcNodeConnection) node);
+ rpcNode.invokeAsync(request, timeoutSeconds, new RpcResponseWaiter(rpcNode, responseReceiver));
+ }
+
+ private static class RpcNodeConnection implements NodeConnection {
+
+ // Information about the connected node
+ private final Supervisor supervisor;
+ private final String hostname;
+ private final int port;
+ private final String description;
+
+ // The current shared connection. This will be recycled when it becomes invalid.
+ // All access to this must be synchronized
+ private Target target = null;
+
+ public RpcNodeConnection(String hostname, int port, Supervisor supervisor) {
+ this.supervisor = supervisor;
+ this.hostname = hostname;
+ this.port = port;
+ description = "rpc node connection to " + hostname + ":" + port;
+ }
+
+ public void invokeAsync(Request req, double timeout, RequestWaiter waiter) {
+ // TODO: Consider replacing this by a watcher on the target
+ synchronized(this) { // ensure we have exactly 1 valid connection across threads
+ if (target == null || ! target.isValid())
+ target = supervisor.connect(new Spec(hostname, port));
+ }
+ target.invokeAsync(req, timeout, waiter);
+ }
+
+ @Override
+ public void close() {
+ target.close();
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+
+ }
+
+ private static class RpcResponseWaiter implements RequestWaiter {
+
+ /** The node to which we made the request we are waiting for - for error messages only */
+ private final RpcNodeConnection node;
+
+ /** The handler to which the response is forwarded */
+ private final Dispatcher.GetDocsumsResponseReceiver handler;
+
+ public RpcResponseWaiter(RpcNodeConnection node, Dispatcher.GetDocsumsResponseReceiver handler) {
+ this.node = node;
+ this.handler = handler;
+ }
+
+ @Override
+ public void handleRequestDone(Request requestWithResponse) {
+ if (requestWithResponse.isError()) {
+ handler.receive(GetDocsumsResponseOrError.fromError("Error response from " + node + ": " +
+ requestWithResponse.errorMessage()));
+ return;
+ }
+
+ Values returnValues = requestWithResponse.returnValues();
+ if (returnValues.size() < 3) {
+ handler.receive(GetDocsumsResponseOrError.fromError("Invalid getDocsums response from " + node +
+ ": Expected 3 return arguments, got " +
+ returnValues.size()));
+ return;
+ }
+
+ byte compression = returnValues.get(0).asInt8();
+ int uncompressedSize = returnValues.get(1).asInt32();
+ byte[] compressedSlimeBytes = returnValues.get(2).asData();
+ List<FastHit> hits = (List<FastHit>) requestWithResponse.getContext();
+ handler.receive(GetDocsumsResponseOrError.fromResponse(new GetDocsumsResponse(compression,
+ uncompressedSize,
+ compressedSlimeBytes,
+ hits)));
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/CommonFields.java b/container-search/src/main/java/com/yahoo/search/federation/CommonFields.java
new file mode 100644
index 00000000000..912a1db6202
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/CommonFields.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation;
+/**
+ * A set of string constants for common hit field names.
+ * @author laboisse
+ *
+ */
+public class CommonFields {
+
+ public static final String TITLE = "title";
+ public static final String URL = "url";
+ public static final String DESCRIPTION = "description";
+ public static final String DATE = "date";
+ public static final String SIZE = "size";
+ public static final String DISP_URL = "dispurl";
+ public static final String BASE_URL = "baseurl";
+ public static final String MIME_TYPE = "mimetype";
+ public static final String RELEVANCY = "relevancy";
+ public static final String THUMBNAIL_URL = "thumbnailUrl";
+ public static final String THUMBNAIL_WIDTH = "thumbnailWidth";
+ public static final String THUMBNAIL_HEIGHT = "thumbnailHeight";
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/FederationSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/FederationSearcher.java
new file mode 100644
index 00000000000..4ec04d0d577
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/FederationSearcher.java
@@ -0,0 +1,948 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation;
+
+import com.google.inject.Inject;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.concurrent.CopyOnWriteHashMap;
+import com.yahoo.errorhandling.Results.Builder;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.federation.selection.FederationTarget;
+import com.yahoo.search.federation.selection.TargetSelector;
+import com.yahoo.search.federation.sourceref.SearchChainInvocationSpec;
+import com.yahoo.search.federation.sourceref.SearchChainResolver;
+import com.yahoo.search.federation.sourceref.SingleTarget;
+import com.yahoo.search.federation.sourceref.SourceRefResolver;
+import com.yahoo.search.federation.sourceref.SourcesTarget;
+import com.yahoo.search.federation.sourceref.Target;
+import com.yahoo.search.federation.sourceref.UnresolvedSearchChainException;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.query.properties.QueryProperties;
+import com.yahoo.search.query.properties.SubProperties;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.result.HitGroup;
+import com.yahoo.search.result.HitOrderer;
+import com.yahoo.search.searchchain.AsyncExecution;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.ForkingSearcher;
+import com.yahoo.search.searchchain.FutureResult;
+import com.yahoo.search.searchchain.SearchChainRegistry;
+import com.yahoo.search.searchchain.model.federation.FederationOptions;
+import com.yahoo.errorhandling.Results;
+
+import org.apache.commons.lang.StringUtils;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.yahoo.collections.CollectionUtil.first;
+import static com.yahoo.container.util.Util.quote;
+import static com.yahoo.search.federation.StrictContractsConfig.PropagateSourceProperties;
+
+import java.util.*;
+import java.util.Map.Entry;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * This searcher takes a set of sources, looks them up in config and fire off the correct searchchains.
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author tonytv
+ */
+@Provides(FederationSearcher.FEDERATION)
+@After("*")
+public class FederationSearcher extends ForkingSearcher {
+ public static final String FEDERATION = "Federation";
+
+ private static abstract class TargetHandler {
+ abstract Chain<Searcher> getChain();
+ abstract void modifyTargetQuery(Query query);
+ abstract void modifyTargetResult(Result result);
+
+ ComponentId getId() {
+ return getChain().getId();
+ }
+
+ public abstract FederationOptions federationOptions();
+
+ @Override
+ public String toString() {
+ return getChain().getId().stringValue();
+ }
+
+ }
+
+ private static class StandardTargetHandler extends TargetHandler {
+ private final SearchChainInvocationSpec target;
+ private final Chain<Searcher> chain;
+
+ public StandardTargetHandler(SearchChainInvocationSpec target, Chain<Searcher> chain) {
+ this.target = target;
+ this.chain = chain;
+ }
+
+ @Override
+ Chain<Searcher> getChain() {
+ return chain;
+ }
+
+ @Override
+ void modifyTargetQuery(Query query) {}
+ @Override
+ void modifyTargetResult(Result result) {}
+
+ @Override
+ public FederationOptions federationOptions() {
+ return target.federationOptions;
+ }
+ }
+
+
+ private static class CustomTargetHandler<T> extends TargetHandler {
+ private final TargetSelector<T> selector;
+ private final FederationTarget<T> target;
+
+ CustomTargetHandler(TargetSelector<T> selector, FederationTarget<T> target) {
+ this.selector = selector;
+ this.target = target;
+ }
+
+ @Override
+ Chain<Searcher> getChain() {
+ return target.getChain();
+ }
+
+ @Override
+ public void modifyTargetQuery(Query query) {
+ selector.modifyTargetQuery(target, query);
+ }
+
+ @Override
+ public void modifyTargetResult(Result result) {
+ selector.modifyTargetResult(target, result);
+ }
+
+ @Override
+ public FederationOptions federationOptions() {
+ return target.getFederationOptions();
+ }
+ }
+
+
+
+ private static class ExecutionInfo {
+ final TargetHandler targetHandler;
+ final FederationOptions federationOptions;
+ final FutureResult futureResult;
+
+ public ExecutionInfo(TargetHandler targetHandler, FederationOptions federationOptions, FutureResult futureResult) {
+ this.targetHandler = targetHandler;
+ this.federationOptions = federationOptions;
+ this.futureResult = futureResult;
+ }
+ }
+
+ private static class CompoundKey {
+ private final String sourceName;
+ private final String propertyName;
+ CompoundKey(String sourceName, String propertyName) {
+ this.sourceName = sourceName;
+ this.propertyName = propertyName;
+ }
+
+ @Override
+ public int hashCode() {
+ return sourceName.hashCode() ^ propertyName.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ CompoundKey rhs = (CompoundKey) o;
+ return sourceName.equals(rhs.sourceName) && propertyName.equals(rhs.propertyName);
+ }
+
+ @Override
+ public String toString() {
+ return sourceName + '.' + propertyName;
+ }
+ }
+
+ private static class SourceKey extends CompoundKey {
+ public static final String SOURCE = "source.";
+ SourceKey(String sourceName, String propertyName) {
+ super(sourceName, propertyName);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode() ^ 7;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return (o instanceof SourceKey) && super.equals(o);
+ }
+
+ @Override
+ public String toString() {
+ return SOURCE + super.toString();
+ }
+ }
+ private static class ProviderKey extends CompoundKey {
+ public static final String PROVIDER = "provider.";
+ ProviderKey(String sourceName, String propertyName) {
+ super(sourceName, propertyName);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode() ^ 17;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return (o instanceof ProviderKey) && super.equals(o);
+ }
+
+ @Override
+ public String toString() {
+ return PROVIDER + super.toString();
+ }
+ }
+
+ private static final Logger log = Logger.getLogger(FederationSearcher.class.getName());
+
+ /** The name of the query property containing the source name added to the query to each source by this */
+ public final static CompoundName SOURCENAME = new CompoundName("sourceName");
+ public final static CompoundName PROVIDERNAME = new CompoundName("providerName");
+
+
+ /** Logging field name constants */
+ public static final String LOG_COUNT_PREFIX = "count_";
+
+ private final SearchChainResolver searchChainResolver;
+ private final PropagateSourceProperties.Enum propagateSourceProperties;
+ private final SourceRefResolver sourceRefResolver;
+ private final CopyOnWriteHashMap<CompoundKey, CompoundName> map = new CopyOnWriteHashMap<>();
+
+ private final boolean strictSearchchain;
+ private final TargetSelector<?> targetSelector;
+
+
+ @Inject
+ public FederationSearcher(FederationConfig config, StrictContractsConfig strict,
+ ComponentRegistry<TargetSelector> targetSelectors) {
+ this(createResolver(config), strict.searchchains(), strict.propagateSourceProperties(),
+ resolveSelector(config.targetSelector(), targetSelectors));
+ }
+
+ private static TargetSelector resolveSelector(String selectorId, ComponentRegistry<TargetSelector> targetSelectors) {
+ if (selectorId.isEmpty())
+ return null;
+
+ return checkNotNull(
+ targetSelectors.getComponent(selectorId),
+ "Missing target selector with id" + quote(selectorId));
+ }
+
+ //for testing
+ public FederationSearcher(ComponentId id, SearchChainResolver searchChainResolver) {
+ this(searchChainResolver, false, PropagateSourceProperties.ALL, null);
+ }
+
+ private FederationSearcher(SearchChainResolver searchChainResolver, boolean strictSearchchain,
+ PropagateSourceProperties.Enum propagateSourceProperties,
+ TargetSelector targetSelector) {
+ this.searchChainResolver = searchChainResolver;
+ sourceRefResolver = new SourceRefResolver(searchChainResolver);
+ this.strictSearchchain = strictSearchchain;
+ this.propagateSourceProperties = propagateSourceProperties;
+ this.targetSelector = targetSelector;
+ }
+
+
+ private static SearchChainResolver createResolver(FederationConfig config) {
+ SearchChainResolver.Builder builder = new SearchChainResolver.Builder();
+
+ for (FederationConfig.Target target : config.target()) {
+ boolean isDefaultProviderForSource = true;
+
+ for (FederationConfig.Target.SearchChain searchChain : target.searchChain()) {
+ if (searchChain.providerId() == null || searchChain.providerId().isEmpty()) {
+ addSearchChain(builder, target, searchChain);
+ } else {
+ addSourceForProvider(builder, target, searchChain, isDefaultProviderForSource);
+ isDefaultProviderForSource = false;
+ }
+ }
+
+ //Allow source groups to use by default.
+ if (target.useByDefault())
+ builder.useTargetByDefault(target.id());
+ }
+
+ return builder.build();
+ }
+
+ private static void addSearchChain(SearchChainResolver.Builder builder,
+ FederationConfig.Target target, FederationConfig.Target.SearchChain searchChain) {
+ if (!target.id().equals(searchChain.searchChainId()))
+ throw new RuntimeException("Invalid federation config, " + target.id() + " != " + searchChain.searchChainId());
+
+ builder.addSearchChain(ComponentId.fromString(searchChain.searchChainId()),
+ federationOptions(searchChain), searchChain.documentTypes());
+ }
+
+ private static void addSourceForProvider(SearchChainResolver.Builder builder, FederationConfig.Target target,
+ FederationConfig.Target.SearchChain searchChain, boolean isDefaultProvider) {
+ builder.addSourceForProvider(
+ ComponentId.fromString(target.id()),
+ ComponentId.fromString(searchChain.providerId()),
+ ComponentId.fromString(searchChain.searchChainId()),
+ isDefaultProvider, federationOptions(searchChain),
+ searchChain.documentTypes());
+ }
+
+ private static FederationOptions federationOptions(FederationConfig.Target.SearchChain searchChain) {
+ return new FederationOptions().
+ setOptional(searchChain.optional()).
+ setUseByDefault(searchChain.useByDefault()).
+ setTimeoutInMilliseconds(searchChain.timeoutMillis()).
+ setRequestTimeoutInMilliseconds(searchChain.requestTimeoutMillis());
+ }
+
+ private static long calculateTimeout(Query query, List<TargetHandler> targets) {
+
+ class PartitionByOptional {
+ final List<TargetHandler> mandatoryTargets;
+ final List<TargetHandler> optionalTargets;
+
+ PartitionByOptional(List<TargetHandler> targets) {
+ List<TargetHandler> mandatoryTargets = new ArrayList<>();
+ List<TargetHandler> optionalTargets = new ArrayList<>();
+
+ for (TargetHandler target : targets) {
+ if (target.federationOptions().getOptional()) {
+ optionalTargets.add(target);
+ } else {
+ mandatoryTargets.add(target);
+ }
+ }
+
+ this.mandatoryTargets = Collections.unmodifiableList(mandatoryTargets);
+ this.optionalTargets = Collections.unmodifiableList(optionalTargets);
+ }
+ }
+
+ if (query.requestHasProperty("timeout") || targets.isEmpty()) {
+ return query.getTimeLeft();
+ } else {
+ PartitionByOptional partition = new PartitionByOptional(targets);
+ long queryTimeout = query.getTimeout();
+
+ return partition.mandatoryTargets.isEmpty() ?
+ maximumTimeout(partition.optionalTargets, queryTimeout) :
+ maximumTimeout(partition.mandatoryTargets, queryTimeout);
+ }
+ }
+
+ private static long maximumTimeout(List<TargetHandler> invocationSpecs, long queryTimeout) {
+ long timeout = 0;
+ for (TargetHandler target : invocationSpecs) {
+ timeout = Math.max(timeout,
+ target.federationOptions().getSearchChainExecutionTimeoutInMilliseconds(queryTimeout));
+ }
+ return timeout;
+ }
+
+ private void addSearchChainTimedOutError(Query query,
+ ComponentId searchChainId) {
+ ErrorMessage timeoutMessage=
+ ErrorMessage.createTimeout("The search chain '" + searchChainId + "' timed out.");
+ timeoutMessage.setSource(searchChainId.stringValue());
+ query.errors().add(timeoutMessage);
+ }
+
+ private void mergeResult(Query query, TargetHandler targetHandler,
+ Result mergedResults, Result result) {
+
+
+ targetHandler.modifyTargetResult(result);
+ final ComponentId searchChainId = targetHandler.getId();
+ Chain<Searcher> searchChain = targetHandler.getChain();
+
+ mergedResults.mergeWith(result);
+ HitGroup group = result.hits();
+ group.setId("source:" + searchChainId.getName());
+
+ group.setSearcherSpecificMetaData(this, searchChain);
+ group.setMeta(false); // Set hit groups as non-meta as a default
+ group.setAuxiliary(true); // Set hit group as auxiliary so that it doesn't contribute to count
+ group.setSource(searchChainId.getName());
+ group.setQuery(result.getQuery());
+
+ for (Iterator<Hit> it = group.unorderedDeepIterator(); it.hasNext();) {
+ Hit hit = it.next();
+ hit.setSearcherSpecificMetaData(this, searchChain);
+ hit.setSource(searchChainId.stringValue());
+
+ // This is the backend request meta hit, that is holding logging information
+ // See HTTPBackendSearcher, where this hit is created
+ if (hit.isMeta() && hit.types().contains("logging")) {
+ // Augment this hit with count fields
+ hit.setField(LOG_COUNT_PREFIX + "deep", result.getDeepHitCount());
+ hit.setField(LOG_COUNT_PREFIX + "total", result.getTotalHitCount());
+ int offset = result.getQuery().getOffset();
+ hit.setField(LOG_COUNT_PREFIX + "first", offset + 1);
+ hit.setField(LOG_COUNT_PREFIX + "last", result.getConcreteHitCount() + offset);
+ }
+
+ }
+ if (query.getTraceLevel()>=4)
+ query.trace("Got " + group.getConcreteSize() + " hits from " + group.getId(),false, 4);
+ mergedResults.hits().add(group);
+ }
+
+ private boolean successfullyCompleted(FutureResult result) {
+ return result.isDone() && !result.isCancelled();
+ }
+
+ private Query setupSingleQuery(Query query, long timeout, TargetHandler targetHandler) {
+ if (strictSearchchain) {
+ query.resetTimeout();
+ return setupFederationQuery(query, query,
+ windowParameters(query.getHits(), query.getOffset()), timeout, targetHandler);
+ } else {
+ return cloneFederationQuery(query,
+ windowParameters(query.getHits(), query.getOffset()), timeout, targetHandler);
+ }
+ }
+
+ private Result startExecuteSingleQuery(Query query, TargetHandler chain, long timeout, Execution execution) {
+ Query outgoing = setupSingleQuery(query, timeout, chain);
+ Execution exec = new Execution(chain.getChain(), execution.context());
+ return exec.search(outgoing);
+ }
+
+ private List<ExecutionInfo> startExecuteQueryForEachTarget(
+ Query query, Collection<TargetHandler> targets, long timeout, Execution execution) {
+
+ List<ExecutionInfo> results = new ArrayList<>();
+
+ Map<String, Object> windowParameters;
+ if (targets.size()==1) // preserve requested top-level offset by default as an optimization
+ windowParameters = Collections.unmodifiableMap(windowParameters(query.getHits(), query.getOffset()));
+ else // request from offset 0 to enable correct upstream blending into a single top-level hit list
+ windowParameters = Collections.unmodifiableMap(windowParameters(query.getHits() + query.getOffset(), 0));
+
+ for (TargetHandler targetHandler : targets) {
+ long executeTimeout = timeout;
+ if (targetHandler.federationOptions().getRequestTimeoutInMilliseconds() != -1)
+ executeTimeout = targetHandler.federationOptions().getRequestTimeoutInMilliseconds();
+ results.add(new ExecutionInfo(targetHandler, targetHandler.federationOptions(),
+ createFutureSearch(query, windowParameters, targetHandler, executeTimeout, execution)));
+ }
+
+ return results;
+ }
+
+ private Map<String, Object> windowParameters(int hits, int offset) {
+ Map<String, Object> params = new HashMap<>();
+ params.put(Query.HITS.toString(), hits);
+ params.put(Query.OFFSET.toString(), offset);
+ return params;
+ }
+
+ private FutureResult createFutureSearch(Query query, Map<String, Object> windowParameters, TargetHandler targetHandler,
+ long timeout, Execution execution) {
+ Query clonedQuery = cloneFederationQuery(query, windowParameters, timeout, targetHandler);
+ return new AsyncExecution(targetHandler.getChain(), execution).search(clonedQuery);
+ }
+
+
+ private Query cloneFederationQuery(Query query,
+ Map<String, Object> windowParameters, long timeout, TargetHandler targetHandler) {
+ Query clonedQuery = Query.createNewQuery(query);
+ return setupFederationQuery(query, clonedQuery, windowParameters, timeout, targetHandler);
+ }
+
+ private Query setupFederationQuery(Query query, Query outgoing,
+ Map<String, Object> windowParameters, long timeout, TargetHandler targetHandler) {
+
+ ComponentId chainId = targetHandler.getChain().getId();
+
+ String sourceName = chainId.getName();
+ outgoing.properties().set(SOURCENAME, sourceName);
+ String providerName = chainId.getName();
+ if (chainId.getNamespace() != null)
+ providerName = chainId.getNamespace().getName();
+ outgoing.properties().set(PROVIDERNAME, providerName);
+
+ outgoing.setTimeout(timeout);
+
+ switch (propagateSourceProperties) {
+ case ALL:
+ propagatePerSourceQueryProperties(query, outgoing, windowParameters, sourceName, providerName,
+ QueryProperties.PER_SOURCE_QUERY_PROPERTIES);
+ break;
+ case OFFSET_HITS:
+ propagatePerSourceQueryProperties(query, outgoing, windowParameters, sourceName, providerName,
+ new CompoundName[]{Query.OFFSET, Query.HITS});
+ break;
+ }
+
+ //TODO: FederationTarget
+ //TODO: only for target produced by this, not others
+ targetHandler.modifyTargetQuery(outgoing);
+ return outgoing;
+ }
+
+ private void propagatePerSourceQueryProperties(Query original, Query outgoing,
+ Map<String, Object> windowParameters,
+ String sourceName, String providerName,
+ CompoundName[] queryProperties) {
+
+ for (CompoundName key : queryProperties) {
+ Object value = getSourceOrProviderProperty(original, key, sourceName, providerName, windowParameters.get(key.toString()));
+ if (value != null) {
+ outgoing.properties().set(key, value);
+ }
+ }
+ }
+
+ private Object getSourceOrProviderProperty(Query query, CompoundName propertyName,
+ String sourceName, String providerName,
+ Object defaultValue) {
+ Object result = getProperty(query, new SourceKey(sourceName, propertyName.toString()));
+ if (result == null)
+ result = getProperty(query, new ProviderKey(providerName, propertyName.toString()));
+ if (result == null)
+ result = defaultValue;
+
+ return result;
+ }
+
+ private Object getProperty(Query query, CompoundKey key) {
+
+ CompoundName name = map.get(key);
+ if (name == null) {
+ name = new CompoundName(key.toString());
+ map.put(key, name);
+ }
+ return query.properties().get(name);
+ }
+
+ private ErrorMessage missingSearchChainsErrorMessage(List<UnresolvedSearchChainException> unresolvedSearchChainExceptions) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(StringUtils.join(getMessagesSet(unresolvedSearchChainExceptions), ' '));
+
+
+ sb.append(" Valid source refs are ");
+ sb.append(
+ StringUtils.join(allSourceRefDescriptions().iterator(),
+ ", ")).append('.');
+
+ return ErrorMessage.createInvalidQueryParameter(sb.toString());
+ }
+
+ private List<String> allSourceRefDescriptions() {
+ List<String> descriptions = new ArrayList<>();
+
+ for (Target target : searchChainResolver.allTopLevelTargets()) {
+ descriptions.add(target.searchRefDescription());
+ }
+ return descriptions;
+ }
+
+ private Set<String> getMessagesSet(List<UnresolvedSearchChainException> unresolvedSearchChainExceptions) {
+ Set<String> messages = new LinkedHashSet<>();
+ for (UnresolvedSearchChainException exception : unresolvedSearchChainExceptions) {
+ messages.add(exception.getMessage());
+ }
+ return messages;
+ }
+
+ private void warnIfUnresolvedSearchChains(List<UnresolvedSearchChainException> missingTargets,
+ HitGroup errorHitGroup) {
+
+ if (!missingTargets.isEmpty()) {
+ errorHitGroup.addError(missingSearchChainsErrorMessage(missingTargets));
+ }
+ }
+
+ @Override
+ public Collection<CommentedSearchChain> getSearchChainsForwarded(SearchChainRegistry registry) {
+ List<CommentedSearchChain> searchChains = new ArrayList<>();
+
+ for (Target target : searchChainResolver.allTopLevelTargets()) {
+ if (target instanceof SourcesTarget) {
+ searchChains.addAll(commentedSourceProviderSearchChains((SourcesTarget)target, registry));
+ } else if (target instanceof SingleTarget) {
+ searchChains.add(commentedSearchChain((SingleTarget)target, registry));
+ } else {
+ log.warning("Invalid target type " + target.getClass().getName());
+ }
+ }
+
+ return searchChains;
+ }
+
+ private CommentedSearchChain commentedSearchChain(SingleTarget singleTarget, SearchChainRegistry registry) {
+ return new CommentedSearchChain("If source refs contains '" + singleTarget.getId() + "'.",
+ registry.getChain(singleTarget.getId()));
+ }
+
+ private List<CommentedSearchChain> commentedSourceProviderSearchChains(SourcesTarget sourcesTarget,
+ SearchChainRegistry registry) {
+
+ List<CommentedSearchChain> commentedSearchChains = new ArrayList<>();
+ String ifMatchingSourceRefPrefix = "If source refs contains '" + sourcesTarget.getId() + "' and provider is '";
+
+ commentedSearchChains.add(
+ new CommentedSearchChain(ifMatchingSourceRefPrefix + sourcesTarget.defaultProviderSource().provider +
+ "'(or not given).", registry.getChain(sourcesTarget.defaultProviderSource().searchChainId)));
+
+ for (SearchChainInvocationSpec providerSource : sourcesTarget.allProviderSources()) {
+ if (!providerSource.equals(sourcesTarget.defaultProviderSource())) {
+ commentedSearchChains.add(
+ new CommentedSearchChain(ifMatchingSourceRefPrefix + providerSource.provider + "'.",
+ registry.getChain(providerSource.searchChainId)));
+ }
+ }
+ return commentedSearchChains;
+ }
+
+ /** Returns the set of properties set for the source or provider given in the query (if any).
+ *
+ * If the query has not set sourceName or providerName, null will be returned */
+ public static Properties getSourceProperties(Query query) {
+ String sourceName = query.properties().getString(SOURCENAME);
+ String providerName = query.properties().getString(PROVIDERNAME);
+ if (sourceName == null || providerName == null)
+ return null;
+ Properties sourceProperties = new SubProperties("source." + sourceName, query.properties());
+ Properties providerProperties = new SubProperties("provider." + providerName, query.properties());
+ sourceProperties.chain(providerProperties);
+ return sourceProperties;
+ }
+
+ @Override
+ public void fill(final Result result, final String summaryClass, Execution execution) {
+ List<FutureResult> filledResults = new ArrayList<>();
+ UniqueExecutionsToResults uniqueExecutionsToResults = new UniqueExecutionsToResults();
+ addResultsToFill(result.hits(), result, summaryClass, uniqueExecutionsToResults);
+ final Set<Entry<Chain<Searcher>, Map<Query, Result>>> resultsForAllChains = uniqueExecutionsToResults.resultsToFill
+ .entrySet();
+ int numberOfCallsToFillNeeded = 0;
+
+ for (Entry<Chain<Searcher>, Map<Query, Result>> resultsToFillForAChain : resultsForAllChains) {
+ numberOfCallsToFillNeeded += resultsToFillForAChain.getValue().size();
+ }
+
+ for (Entry<Chain<Searcher>, Map<Query, Result>> resultsToFillForAChain : resultsForAllChains) {
+ Chain<Searcher> chain = resultsToFillForAChain.getKey();
+ Execution chainExecution = (chain == null) ? execution : new Execution(chain, execution.context());
+
+ for (Entry<Query, Result> resultsToFillForAChainAndQuery : resultsToFillForAChain.getValue().entrySet()) {
+ Result resultToFill = resultsToFillForAChainAndQuery.getValue();
+ if (numberOfCallsToFillNeeded == 1) {
+ chainExecution.fill(resultToFill, summaryClass);
+ propagateErrors(resultToFill, result);
+ } else {
+ AsyncExecution asyncFill = new AsyncExecution(chainExecution);
+ filledResults.add(asyncFill.fill(resultToFill, summaryClass));
+ }
+ }
+ }
+ for (FutureResult filledResult : filledResults) {
+ propagateErrors(filledResult.get(result.getQuery().getTimeLeft(), TimeUnit.MILLISECONDS), result);
+ }
+ }
+
+ private void propagateErrors(Result source, Result destination) {
+ ErrorMessage error = source.hits().getError();
+ if (error != null)
+ destination.hits().addError(error);
+ }
+
+ /** A map from a unique search chain and query instance to a result */
+ private static class UniqueExecutionsToResults {
+
+ /** Implemented as a nested identity hashmap */
+ final Map<Chain<Searcher>,Map<Query,Result>> resultsToFill = new IdentityHashMap<>();
+
+ /** Returns a result to fill for a query and chain, by creating it if necessary */
+ public Result get(Chain<Searcher> chain, Query query) {
+ Map<Query,Result> resultsToFillForAChain = resultsToFill.get(chain);
+ if (resultsToFillForAChain == null) {
+ resultsToFillForAChain = new IdentityHashMap<>();
+ resultsToFill.put(chain,resultsToFillForAChain);
+ }
+
+ Result resultsToFillForAChainAndQuery = resultsToFillForAChain.get(query);
+ if (resultsToFillForAChainAndQuery == null) {
+ resultsToFillForAChainAndQuery = new Result(query);
+ resultsToFillForAChain.put(query,resultsToFillForAChainAndQuery);
+ }
+
+ return resultsToFillForAChainAndQuery;
+ }
+
+ }
+
+ private void addResultsToFill(HitGroup hitGroup, Result result, String summaryClass,
+ UniqueExecutionsToResults uniqueExecutionsToResults) {
+ for (Hit hit : hitGroup) {
+ if (hit instanceof HitGroup) {
+ addResultsToFill((HitGroup) hit, result, summaryClass, uniqueExecutionsToResults);
+ } else {
+ if ( ! hit.isFilled(summaryClass))
+ getSearchChainGroup(hit,result,uniqueExecutionsToResults).hits().add(hit);
+ }
+ }
+ }
+
+ private Result getSearchChainGroup(Hit hit, Result result, UniqueExecutionsToResults uniqueExecutionsToResults) {
+ @SuppressWarnings("unchecked")
+ Chain<Searcher> chain = (Chain<Searcher>) hit.getSearcherSpecificMetaData(this);
+ Query query = hit.getQuery() !=null ? hit.getQuery() : result.getQuery();
+
+ return uniqueExecutionsToResults.get(chain,query);
+ }
+
+ private void searchMultipleTargets(Query query, Result mergedResults,
+ Collection<TargetHandler> targets,
+ long timeout,
+ Execution execution) {
+
+ List<ExecutionInfo> executionInfos = startExecuteQueryForEachTarget(query, targets, timeout, execution);
+ waitForMandatoryTargets(executionInfos, query.getTimeout());
+
+ HitOrderer s=null;
+ for (ExecutionInfo executionInfo : executionInfos) {
+ if ( ! successfullyCompleted(executionInfo.futureResult)) {
+ addSearchChainTimedOutError(query, executionInfo.targetHandler.getId());
+ } else {
+ if (s == null) {
+ s = dirtyCopyIfModifiedOrderer(mergedResults.hits(), executionInfo.futureResult.get().hits().getOrderer());
+ }
+ mergeResult(query, executionInfo.targetHandler, mergedResults, executionInfo.futureResult.get());
+
+ }
+ }
+ }
+
+ /**
+ * TODO This is probably a dirty hack for bug 4711376. There are probably better ways.
+ * But I will leave that to trd-processing@
+ *
+ * @param group The merging hitgroup to be updated if necessary
+ * @param orderer The per provider hit orderer.
+ * @return The hitorderer chosen
+ */
+ private HitOrderer dirtyCopyIfModifiedOrderer(HitGroup group, HitOrderer orderer) {
+ if (orderer != null) {
+ HitOrderer old = group.getOrderer();
+ if ((old == null) || ! orderer.equals(old)) {
+ group.setOrderer(orderer);
+ }
+ }
+
+ return orderer;
+ }
+
+ private void waitForMandatoryTargets(List<ExecutionInfo> executionInfos, long queryTimeout) {
+ FutureWaiter futureWaiter = new FutureWaiter();
+
+ boolean hasMandatoryTargets = false;
+ for (ExecutionInfo executionInfo : executionInfos) {
+ if (isMandatory(executionInfo)) {
+ futureWaiter.add(executionInfo.futureResult,
+ getSearchChainExecutionTimeoutInMilliseconds(executionInfo, queryTimeout));
+ hasMandatoryTargets = true;
+ }
+ }
+
+ if (!hasMandatoryTargets) {
+ for (ExecutionInfo executionInfo : executionInfos) {
+ futureWaiter.add(executionInfo.futureResult,
+ getSearchChainExecutionTimeoutInMilliseconds(executionInfo, queryTimeout));
+ }
+ }
+
+ futureWaiter.waitForFutures();
+ }
+
+ private long getSearchChainExecutionTimeoutInMilliseconds(ExecutionInfo executionInfo, long queryTimeout) {
+ return executionInfo.federationOptions.
+ getSearchChainExecutionTimeoutInMilliseconds(queryTimeout);
+ }
+
+ private boolean isMandatory(ExecutionInfo executionInfo) {
+ return !executionInfo.federationOptions.getOptional();
+ }
+
+ private void searchSingleTarget(Query query, Result mergedResults,
+ TargetHandler targetHandler,
+ long timeout,
+ Execution execution) {
+ Result result = startExecuteSingleQuery(query, targetHandler, timeout, execution);
+ mergeResult(query, targetHandler, mergedResults, result);
+ }
+
+
+ private Results<SearchChainInvocationSpec, UnresolvedSearchChainException> getTargets(Set<String> sources, Properties properties, IndexFacts indexFacts) {
+ return sources.isEmpty() ?
+ defaultSearchChains(properties):
+ resolveSources(sources, properties, indexFacts);
+ }
+
+ private Results<SearchChainInvocationSpec, UnresolvedSearchChainException> resolveSources(Set<String> sources, Properties properties, IndexFacts indexFacts) {
+ Results.Builder<SearchChainInvocationSpec, UnresolvedSearchChainException> result = new Builder<>();
+
+ for (String source : sources) {
+ try {
+ result.addAllData(sourceRefResolver.resolve(asSourceSpec(source), properties, indexFacts));
+ } catch (UnresolvedSearchChainException e) {
+ result.addError(e);
+ }
+ }
+
+ return result.build();
+ }
+
+
+ public Results<SearchChainInvocationSpec, UnresolvedSearchChainException> defaultSearchChains(Properties sourceToProviderMap) {
+ Results.Builder<SearchChainInvocationSpec, UnresolvedSearchChainException> result = new Builder<>();
+
+ for (Target target : searchChainResolver.defaultTargets()) {
+ try {
+ result.addData(target.responsibleSearchChain(sourceToProviderMap));
+ } catch (UnresolvedSearchChainException e) {
+ result.addError(e);
+ }
+ }
+
+ return result.build();
+ }
+
+
+ private ComponentSpecification asSourceSpec(String source) {
+ try {
+ return new ComponentSpecification(source);
+ } catch(Exception e) {
+ throw new IllegalArgumentException("The source ref '" + source
+ + "' used for federation is not valid.", e);
+ }
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ Result mergedResults = execution.search(query);
+
+ Results<SearchChainInvocationSpec, UnresolvedSearchChainException> targets =
+ getTargets(query.getModel().getSources(), query.properties(), execution.context().getIndexFacts());
+ warnIfUnresolvedSearchChains(targets.errors(), mergedResults.hits());
+
+ Collection<SearchChainInvocationSpec> prunedTargets =
+ pruneTargetsWithoutDocumentTypes(query.getModel().getRestrict(), targets.data());
+
+ Results<TargetHandler, ErrorMessage> regularTargetHandlers = resolveSearchChains(prunedTargets, execution.searchChainRegistry());
+ query.errors().addAll(regularTargetHandlers.errors());
+
+ List<TargetHandler> targetHandlers = new ArrayList<>(regularTargetHandlers.data());
+ targetHandlers.addAll(getAdditionalTargets(query, execution, targetSelector));
+
+ final long targetsTimeout = calculateTimeout(query, targetHandlers);
+ if (targetsTimeout < 0)
+ return new Result(query, ErrorMessage.createTimeout("Timed out when about to federate"));
+
+ traceTargets(query, targetHandlers);
+
+ if (targetHandlers.size() == 0) {
+ return mergedResults;
+ } else if (targetHandlers.size() == 1 && ! shouldExecuteTargetLongerThanThread(query, targetHandlers.get(0))) {
+ TargetHandler chain = first(targetHandlers);
+ searchSingleTarget(query, mergedResults, chain, targetsTimeout, execution);
+ } else {
+ searchMultipleTargets(query, mergedResults, targetHandlers, targetsTimeout, execution);
+ }
+
+ return mergedResults;
+ }
+
+ private void traceTargets(Query query, List<TargetHandler> targetHandlers) {
+ final int traceFederationLevel = 2;
+ if ( ! query.isTraceable(traceFederationLevel)) return;
+ query.trace("Federating to " + targetHandlers, traceFederationLevel);
+ }
+
+ /**
+ * Returns true if we are requested to keep executing a target longer than we're waiting for it.
+ * This is useful to populate caches inside targets.
+ */
+ private boolean shouldExecuteTargetLongerThanThread(Query query, TargetHandler target) {
+ return target.federationOptions().getRequestTimeoutInMilliseconds() > query.getTimeout();
+ }
+
+ private static Results<TargetHandler, ErrorMessage> resolveSearchChains(
+ Collection<SearchChainInvocationSpec> prunedTargets,
+ SearchChainRegistry registry) {
+
+ Results.Builder<TargetHandler, ErrorMessage> targetHandlers = new Results.Builder<>();
+
+ for (SearchChainInvocationSpec target: prunedTargets) {
+ Chain<Searcher> chain = registry.getChain(target.searchChainId);
+ if (chain == null) {
+ targetHandlers.addError(ErrorMessage.createIllegalQuery(
+ "Could not find search chain '" + target.searchChainId + "'"));
+ } else {
+ targetHandlers.addData(new StandardTargetHandler(target, chain));
+ }
+ }
+
+ return targetHandlers.build();
+ }
+
+ private static <T> List<TargetHandler> getAdditionalTargets(Query query, Execution execution, TargetSelector<T> targetSelector) {
+ if (targetSelector == null)
+ return Collections.emptyList();
+
+ ArrayList<TargetHandler> result = new ArrayList<>();
+ for (FederationTarget<T> target: targetSelector.getTargets(query, execution.searchChainRegistry()))
+ result.add(new CustomTargetHandler<>(targetSelector, target));
+
+ return result;
+ }
+
+ private Collection<SearchChainInvocationSpec> pruneTargetsWithoutDocumentTypes(Set<String> restrict, List<SearchChainInvocationSpec> targets) {
+ if (restrict.isEmpty())
+ return targets;
+
+ Collection<SearchChainInvocationSpec> prunedTargets = new ArrayList<>();
+
+ for (SearchChainInvocationSpec target : targets) {
+ if (target.documentTypes.isEmpty() || documentTypeIntersectionIsNonEmpty(restrict, target))
+ prunedTargets.add(target);
+ }
+
+ return prunedTargets;
+ }
+
+ private boolean documentTypeIntersectionIsNonEmpty(Set<String> restrict, SearchChainInvocationSpec target) {
+ for (String documentType : target.documentTypes) {
+ if (restrict.contains(documentType))
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/ForwardingSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/ForwardingSearcher.java
new file mode 100644
index 00000000000..b43798113de
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/ForwardingSearcher.java
@@ -0,0 +1,106 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation;
+
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.prelude.Ping;
+import com.yahoo.prelude.Pong;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.cluster.PingableSearcher;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.HitGroup;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * A lightweight searcher to forward all incoming requests to a single search
+ * chain defined in config. An alternative to federation searcher when standard
+ * semantics are not necessary for the application.
+ *
+ * @see FederationSearcher
+ * @since 5.0.13
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@After("*")
+public class ForwardingSearcher extends PingableSearcher {
+ private final ComponentSpecification target;
+
+ public ForwardingSearcher(final SearchchainForwardConfig config) {
+ if (config.target() == null) {
+ throw new RuntimeException(
+ "Configuration value searchchain-forward.target was null.");
+ }
+ try {
+ target = new ComponentSpecification(config.target());
+ } catch (RuntimeException e) {
+ throw new RuntimeException(
+ "Failed constructing the component specification from searchchain-forward.target: "
+ + config.target(), e);
+ }
+ }
+
+ @Override
+ public Result search(final Query query, final Execution execution) {
+ Execution next = createForward(execution);
+
+ if (next == null) {
+ return badResult(query);
+ } else {
+ return next.search(query);
+ }
+ }
+
+ private Result badResult(final Query query) {
+ final ErrorMessage error = noSearchchain();
+ return new Result(query, error);
+ }
+
+ @Override
+ public Pong ping(final Ping ping, final Execution execution) {
+ Execution next = createForward(execution);
+
+ if (next == null) {
+ return badPong();
+ } else {
+ return next.ping(ping);
+ }
+ }
+
+ private Pong badPong() {
+ final Pong pong = new Pong();
+ pong.addError(noSearchchain());
+ return pong;
+ }
+
+ @Override
+ public void fill(final Result result, final String summaryClass,
+ final Execution execution) {
+ Execution next = createForward(execution);
+ if (next == null) {
+ badFill(result.hits());
+ return;
+ } else {
+ next.fill(result, summaryClass);
+ }
+ }
+
+ private void badFill(HitGroup hits) {
+ hits.addError(noSearchchain());
+ }
+
+ private Execution createForward(Execution execution) {
+ Chain<Searcher> targetChain = execution.context().searchChainRegistry()
+ .getComponent(target);
+ if (targetChain == null) {
+ return null;
+ }
+ return new Execution(targetChain, execution.context());
+ }
+
+ private ErrorMessage noSearchchain() {
+ return ErrorMessage
+ .createServerIsMisconfigured("Could not get search chain matching component specification: " + target);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/FutureWaiter.java b/container-search/src/main/java/com/yahoo/search/federation/FutureWaiter.java
new file mode 100644
index 00000000000..52cd5397489
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/FutureWaiter.java
@@ -0,0 +1,58 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation;
+
+import com.yahoo.search.searchchain.FutureResult;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author tonytv
+ */
+class FutureWaiter {
+ private class Future {
+ final FutureResult result;
+ final long timeoutInMilliseconds;
+
+ public Future(FutureResult result, long timeoutInMilliseconds) {
+ this.result = result;
+ this.timeoutInMilliseconds = timeoutInMilliseconds;
+ }
+ }
+
+ private List<Future> futures = new ArrayList<>();
+
+ public void add(FutureResult futureResult, long timeoutInMilliseconds) {
+ futures.add(new Future(futureResult, timeoutInMilliseconds));
+ }
+
+ public void waitForFutures() {
+ sortFuturesByTimeoutDescending();
+
+ final long startTime = System.currentTimeMillis();
+
+ for (Future future : futures) {
+ long timeToWait = startTime + future.timeoutInMilliseconds - System.currentTimeMillis();
+ if (timeToWait <= 0)
+ break;
+
+ future.result.get(timeToWait, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ private void sortFuturesByTimeoutDescending() {
+ Collections.sort(futures, new Comparator<Future>() {
+ @Override
+ public int compare(Future lhs, Future rhs) {
+ return -compareLongs(lhs.timeoutInMilliseconds, rhs.timeoutInMilliseconds);
+ }
+
+ private int compareLongs(long lhs, long rhs) {
+ return new Long(lhs).compareTo(rhs);
+ }
+ });
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/TimeoutException.java b/container-search/src/main/java/com/yahoo/search/federation/TimeoutException.java
new file mode 100644
index 00000000000..8b7e8a1d9d5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/TimeoutException.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation;
+
+/**
+ * Thrown on timeouts
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+@SuppressWarnings("serial")
+public class TimeoutException extends RuntimeException {
+
+ public TimeoutException(String message) {
+ super(message);
+ }
+
+ public TimeoutException(String message,Throwable cause) {
+ super(message,cause);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPClientSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPClientSearcher.java
new file mode 100644
index 00000000000..576c16f68db
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPClientSearcher.java
@@ -0,0 +1,36 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import java.util.Collections;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.search.federation.ProviderConfig;
+import com.yahoo.search.Result;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.statistics.Statistics;
+
+
+/**
+ * Superclass for http client searchers which depends on config. All this is doing is translating
+ * the provider and cache configurations to parameters which are passed upwards.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class ConfiguredHTTPClientSearcher extends HTTPClientSearcher {
+
+ /** Create this from a configuraton */
+ public ConfiguredHTTPClientSearcher(final ComponentId id, final ProviderConfig providerConfig, Statistics manager) {
+ super(id, ConfiguredSearcherHelper.toConnectionList(providerConfig), new HTTPParameters(providerConfig), manager);
+ }
+
+ /** Create an instance from direct parameters having a single connection. Useful for testing */
+ public ConfiguredHTTPClientSearcher(String idString,String host,int port,String path, Statistics manager) {
+ super(new ComponentId(idString), Collections.singletonList(new Connection(host,port)),path, manager);
+ }
+
+ /** Forwards to the next in chain fill(result,summaryName) */
+ public @Override void fill(Result result,String summaryName, Execution execution,Connection connection) {
+ execution.fill(result,summaryName);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPProviderSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPProviderSearcher.java
new file mode 100644
index 00000000000..25253f768bd
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredHTTPProviderSearcher.java
@@ -0,0 +1,68 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.search.federation.ProviderConfig;
+import com.yahoo.search.cache.QrBinaryCacheConfig;
+import com.yahoo.search.cache.QrBinaryCacheRegionConfig;
+import com.yahoo.search.Result;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.statistics.Statistics;
+
+import java.util.Collections;
+
+
+/**
+ * Superclass for http provider searchers which depends on config. All this is doing is translating
+ * the provider and cache configurations to parameters which are passed upwards.
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author bratseth
+ */
+public abstract class ConfiguredHTTPProviderSearcher extends HTTPProviderSearcher {
+
+ /** Create this from a configuraton */
+ public ConfiguredHTTPProviderSearcher(final ComponentId id, final ProviderConfig providerConfig, Statistics manager) {
+ super(id,ConfiguredSearcherHelper.toConnectionList(providerConfig),new HTTPParameters(providerConfig), manager);
+ }
+
+ /** Create this from a configuraton */
+ public ConfiguredHTTPProviderSearcher(final ComponentId id, final ProviderConfig providerConfig,
+ HTTPParameters parameters, Statistics manager) {
+ super(id,ConfiguredSearcherHelper.toConnectionList(providerConfig),parameters, manager);
+ }
+
+ /** Create this from a configuraton with a configured cache */
+ public ConfiguredHTTPProviderSearcher(final ComponentId id, final ProviderConfig providerConfig,
+ final QrBinaryCacheConfig cacheConfig,
+ final QrBinaryCacheRegionConfig regionConfig, Statistics manager) {
+ super(id,ConfiguredSearcherHelper.toConnectionList(providerConfig),new HTTPParameters(providerConfig), manager);
+ configureCache(cacheConfig,regionConfig);
+ }
+
+ /** Create this from a configuraton with a configured cache */
+ public ConfiguredHTTPProviderSearcher(final ComponentId id, final ProviderConfig providerConfig,
+ final QrBinaryCacheConfig cacheConfig,
+ final QrBinaryCacheRegionConfig regionConfig, HTTPParameters parameters, Statistics manager) {
+ super(id,ConfiguredSearcherHelper.toConnectionList(providerConfig),parameters, manager);
+ configureCache(cacheConfig,regionConfig);
+ }
+
+ /** Create an instance from direct parameters having a single connection. Useful for testing */
+ public ConfiguredHTTPProviderSearcher(String idString,String host,int port,String path, Statistics manager) {
+ super(new ComponentId(idString), Collections.singletonList(new Connection(host,port)),path, manager);
+ }
+
+ /** Create an instance from direct parameters having a single connection. Useful for testing */
+ public ConfiguredHTTPProviderSearcher(String idString,String host,int port,HTTPParameters parameters, Statistics manager) {
+ super(new ComponentId(idString), Collections.singletonList(new Connection(host,port)),parameters, manager);
+ }
+
+ /**
+ * Override this to provider multi-phase result filling towards a backend.
+ * This default implementation does nothing.
+ */
+ public @Override void fill(Result result,String summaryName, Execution execution,Connection connection) {
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredSearcherHelper.java b/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredSearcherHelper.java
new file mode 100644
index 00000000000..8d3ee016b4f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/ConfiguredSearcherHelper.java
@@ -0,0 +1,27 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.yahoo.search.federation.ProviderConfig;
+
+/**
+ * Some static helper classes for configured*Searcher classes
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+class ConfiguredSearcherHelper {
+
+ /** No instantiation */
+ private ConfiguredSearcherHelper() { }
+
+ public static List<Connection> toConnectionList(ProviderConfig providerConfig) {
+ List<Connection> connections=new ArrayList<>();
+ for(ProviderConfig.Node node : providerConfig.node()) {
+ connections.add(new Connection(node.host(), node.port()));
+ }
+ return connections;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/Connection.java b/container-search/src/main/java/com/yahoo/search/federation/http/Connection.java
new file mode 100644
index 00000000000..88e2c6ad0a0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/Connection.java
@@ -0,0 +1,30 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+/**
+ * Represents a connection to a particular node (host/port).
+ * Right now this is just a container of connection parameters, but might be extended to
+ * contain an open connection later.
+ * The host and port state is immutable.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Connection {
+
+ private String host;
+ private int port;
+
+ public Connection(String host,int port) {
+ this.host=host;
+ this.port=port;
+ }
+
+ public String getHost() { return host; }
+
+ public int getPort() { return port; }
+
+ public String toString() {
+ return "http connection '" + host + ":" + port + "'";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/GzipDecompressingEntity.java b/container-search/src/main/java/com/yahoo/search/federation/http/GzipDecompressingEntity.java
new file mode 100644
index 00000000000..1dc58ecd65e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/GzipDecompressingEntity.java
@@ -0,0 +1,125 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.entity.HttpEntityWrapper;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Used by HTTPSearcher when talking to services returning compressed content.
+ *
+ * @author <a href="mailto:mainak@yahoo-inc.com">Mainak Mandal</a>
+ */
+public class GzipDecompressingEntity extends HttpEntityWrapper {
+
+ private static class Resources {
+
+ byte [] buffer;
+ int total;
+
+ Resources() {
+ total = 0;
+ buffer = new byte[65536];
+ }
+ void drain(InputStream zipStream) throws IOException {
+ int numRead = zipStream.read(buffer, total, buffer.length);
+ while (numRead != -1) {
+ total += numRead;
+ if ((total + 65536) > buffer.length) {
+ buffer = Arrays.copyOf(buffer, buffer.length + numRead);
+ }
+ numRead = zipStream.read(buffer, total, buffer.length - total);
+ }
+ }
+
+ }
+
+ private final Resources resources = new Resources();
+
+ public GzipDecompressingEntity(final HttpEntity entity) throws IllegalStateException, IOException {
+ super(entity);
+ GZIPInputStream gz = new GZIPInputStream(entity.getContent());
+ InputStream zipStream = new BufferedInputStream(gz);
+ try {
+ resources.drain(zipStream);
+ } catch (IOException e) {
+ throw e;
+ } finally {
+ zipStream.close();
+ }
+ }
+
+ @Override
+ public InputStream getContent() throws IOException, IllegalStateException {
+
+ final ByteBuffer buff = ByteBuffer.wrap(resources.buffer, 0, resources.total);
+ return new InputStream() {
+
+ @Override
+ public int available() throws IOException {
+ return buff.remaining();
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (buff.hasRemaining())
+ return buff.get() & 0xFF;
+
+ return -1;
+ }
+
+ @Override
+ public int read(byte[] b) throws IOException {
+ if (!buff.hasRemaining())
+ return -1;
+
+ int len = b.length;
+ if (len > buff.remaining())
+ len = buff.remaining();
+ buff.get(b, 0, len);
+ return len;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (!buff.hasRemaining())
+ return -1;
+
+ if (len > buff.remaining())
+ len = buff.remaining();
+ buff.get(b, off, len);
+ return len;
+ }
+
+ @Override
+ public long skip(long n) throws IOException {
+ if (!buff.hasRemaining())
+ return -1;
+
+ if (n > buff.remaining())
+ n = buff.remaining();
+
+ buff.position(buff.position() + (int) n);
+ return n;
+ }
+ };
+ }
+
+ @Override
+ public long getContentLength() {
+ return resources.total;
+ }
+
+ @Override
+ public void writeTo(OutputStream outstream) throws IOException {
+ outstream.write(resources.buffer, 0, resources.total);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/HTTPClientSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPClientSearcher.java
new file mode 100644
index 00000000000..1459fb6f226
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPClientSearcher.java
@@ -0,0 +1,276 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.jdisc.http.CertificateStore;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.statistics.Statistics;
+
+import org.apache.http.HttpEntity;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+/**
+ * A utility parent for searchers which gets data from web services which is incorporated into the query.
+ * This searcher will take care of implementing the search method while the extending class implements
+ * {@link #getQueryMap} and {@link #handleResponse} to create the http request and handle the response, respectively.
+ *
+ * <p>This class automatically adds a meta hit containing latency and other
+ * meta information about the obtained HTTP data using createRequestMeta().
+ * The fields available in the hit are:</p>
+ *
+ * <dl><dt>
+ * HTTPSearcher.LOG_LATENCY_START
+ * <dd>
+ * The latency of the external provider answering a request.
+ * <dt>
+ * HTTPSearcher.LOG_LATENCY_FINISH
+ * <dd>
+ * Total time of the HTTP traffic, but also decoding of the data, is this
+ * happens at the same time.
+ * <dt>
+ * HTTPSearcher.LOG_URI
+ * <dd>
+ * The complete URI used for external service.
+ * <dt>
+ * HTTPSearcher.LOG_SCHEME
+ * <dd>
+ * The scheme of the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_HOST
+ * <dd>
+ * The host used for the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_PORT
+ * <dd>
+ * The port used for the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_PATH
+ * <dd>
+ * Path element of the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_STATUS
+ * <dd>
+ * Status code of the HTTP response.
+ * <dt>
+ * HTTPSearcher.LOG_PROXY_TYPE
+ * <dd>
+ * The proxy type used, if any. Default is "http".
+ * <dt>
+ * HTTPSearcher.LOG_PROXY_HOST
+ * <dd>
+ * The proxy host, if any.
+ * <dt>
+ * HTTPSearcher.LOG_PROXY_PORT
+ * <dd>
+ * The proxy port, if any.
+ * <dt>
+ * HTTPSearcher.LOG_HEADER_PREFIX prepended to request header field name
+ * <dd>
+ * The content of any additional request header fields.
+ * <dt>
+ * HTTPSearcher.LOG_RESPONSE_HEADER_PREFIX prepended to response header field name
+ * <dd>
+ * The content of any additional response header fields.
+ * </dl>
+
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author bratseth
+ */
+public abstract class HTTPClientSearcher extends HTTPSearcher {
+
+ static final CompoundName REQUEST_META_CARRIER = new CompoundName("com.yahoo.search.federation.http.HTTPClientSearcher_requestMeta");
+
+ protected final static Logger log = Logger.getLogger(HTTPClientSearcher.class.getName());
+
+ /**
+ * Creates a client searcher
+ *
+ * @param id the id of this instance
+ * @param connections the connections this will load balance and fail over between
+ * @param path the path portion of the url to be used
+ */
+ public HTTPClientSearcher(ComponentId id, List<Connection> connections,String path,Statistics statistics) {
+ super(id, connections, path, statistics);
+ }
+
+ public HTTPClientSearcher(ComponentId id, List<Connection> connections,String path,Statistics statistics,
+ CertificateStore certificateStore) {
+ super(id, connections, path, statistics, certificateStore);
+ }
+
+ public HTTPClientSearcher(ComponentId id, List<Connection> connections, HTTPParameters parameters, Statistics statistics) {
+ super(id, connections, parameters, statistics);
+ }
+ /**
+ * Creates a client searcher
+ *
+ * @param id the id of this instance
+ * @param connections the connections this will load balance and fail over between
+ * @param parameters the parameters to use when making http calls
+ * @param certificateStore the certificate store to use to pass certificates in requests
+ */
+ public HTTPClientSearcher(ComponentId id, List<Connection> connections, HTTPParameters parameters,
+ Statistics statistics, CertificateStore certificateStore) {
+ super(id, connections, parameters, statistics, certificateStore);
+ }
+
+ /** Overridden to avoid interfering with errors from nested searchers, which is inappropriate for a <i>client</i> */
+ @Override
+ public Result robustSearch(Query query, Execution execution, Connection connection) {
+ return search(query,execution,connection);
+ }
+
+ /** Implements a search towards the connection chosen by the cluster searcher for this query */
+ @Override
+ public Result search(Query query, Execution execution, Connection connection) {
+ Hit requestMeta = doHttpRequest(query, connection);
+ Result result = execution.search(query);
+ result.hits().add(requestMeta);
+ return result;
+ }
+
+ private Hit doHttpRequest(Query query, Connection connection) {
+ URI uri;
+ // Create default meta hit for holding logging information
+ Hit requestMeta = createRequestMeta();
+ query.properties().set(REQUEST_META_CARRIER, requestMeta);
+
+ query.trace("Created request information hit",false,9);
+ try {
+ uri = getURI(query, connection);
+ } catch (MalformedURLException e) {
+ query.errors().add(createMalformedUrlError(query,e));
+ return requestMeta;
+ } catch (URISyntaxException e) {
+ query.errors().add(createMalformedUrlError(query,e));
+ return requestMeta;
+ }
+
+ HttpEntity entity;
+ try {
+ if (query.getTraceLevel()>=1)
+ query.trace("Fetching " + uri.toString(), false, 1);
+ entity = getEntity(uri, requestMeta, query);
+ } catch (IOException e) {
+ query.errors().add(ErrorMessage.createBackendCommunicationError(
+ "Error when trying to connect to HTTP backend in " + this + " using " + connection + " for " +
+ query + ": " + Exceptions.toMessageString(e)));
+ return requestMeta;
+ } catch (TimeoutException e) {
+ query.errors().add(ErrorMessage.createTimeout("HTTP traffic timed out in "
+ + this + " for " + query + ": " + e.getMessage()));
+ return requestMeta;
+ }
+ if (entity==null) {
+ query.errors().add(ErrorMessage.createBackendCommunicationError(
+ "No result from connecting to HTTP backend in " + this + " using " + connection + " for " + query));
+ return requestMeta;
+ }
+
+ try {
+ query = handleResponse(entity,query);
+ }
+ catch (IOException e) {
+ query.errors().add(ErrorMessage.createBackendCommunicationError(
+ "Error when trying to consume input in " + this + ": " + Exceptions.toMessageString(e)));
+ } finally {
+ cleanupHttpEntity(entity);
+ }
+ return requestMeta;
+ }
+
+ /** Overrides to pass the query on to the next searcher */
+ @Override
+ public Result search(Query query, Execution execution, ErrorMessage error) {
+ query.errors().add(error);
+ return execution.search(query);
+ }
+
+ /** Do nothing on fill in client searchers */
+ @Override
+ public void fill(Result result,String summaryClass,Execution execution,Connection connection) {
+ }
+
+ /**
+ * Convenience hook for unmarshalling the response and adding the information to the query.
+ * Implement this or <code>handleResponse(entity,query)</code> in any subclass.
+ * This default implementation throws an exception.
+ *
+ * @param inputStream the stream containing the data from the http service
+ * @param contentLength the length of the content in the stream in bytes, or a negative number if not known
+ * @param query the current query, to which information from the stream should be added
+ * @return query the query to propagate down the chain. This should almost always be the
+ * query instance given as a parameter.
+ */
+ public Query handleResponse(InputStream inputStream, long contentLength, Query query) throws IOException {
+ throw new UnsupportedOperationException("handleResponse must be implemented by " + this);
+ }
+
+ /**
+ * Unmarshals the response and adds the resulting data to the given query.
+ * This default implementation calls
+ * <code>return handleResponse(entity.getContent(), entity.getContentLength(), query);</code>
+ * (and does some detailed query tracing).
+ *
+ * @param query the current query, to which information from the stream should be added
+ * @return query the query to propagate down the chain. This should almost always be the
+ * query instance given as a parameter.
+ */
+ public Query handleResponse(HttpEntity entity, Query query) throws IOException {
+ long len = entity.getContentLength();
+ if (query.getTraceLevel()>=4)
+ query.trace("Received " + len + " bytes response in " + this, false, 4);
+ query = handleResponse(entity.getContent(), len, query);
+ if (query.getTraceLevel()>=2)
+ query.trace("Handled " + len + " bytes response in " + this, false, 2);
+ return query;
+ }
+
+ /** Never retry individual queries to clients for now */
+ @Override
+ protected boolean shouldRetry(Query query,Result result) { return false; }
+
+ /**
+ * numHits and offset should not be part of the cache key as cache supports
+ * partial read/write that is only one cache entry is maintained per query
+ * irrespective of the offset and numhits.
+ */
+ public abstract Map<String, String> getCacheKey(Query q);
+
+ /**
+ * Adds all key-values starting by "service." + getClientName() in query.properties().
+ * Returns the empty map if {@link #getServiceName} is not overridden.
+ */
+ @Override
+ public Map<String,String> getQueryMap(Query query) {
+ LinkedHashMap<String, String> queryMap=new LinkedHashMap<>();
+ if (getServiceName().isEmpty()) return queryMap;
+
+ for (Map.Entry<String,Object> objectProperty : query.properties().listProperties("service." + getServiceName()).entrySet()) // TODO: Make more efficient using CompoundName
+ queryMap.put(objectProperty.getKey(),objectProperty.getValue().toString());
+ return queryMap;
+ }
+
+ /**
+ * Override this to return the name of the service this is a client of.
+ * This is used to look up service specific properties as service.getServiceName.serviceSpecificProperty.
+ * This default implementation returns "", which means service specific parameters will not be used.
+ */
+ protected String getServiceName() { return ""; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/HTTPParameters.java b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPParameters.java
new file mode 100644
index 00000000000..19fe1df3e2e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPParameters.java
@@ -0,0 +1,315 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import com.google.common.base.Preconditions;
+import com.yahoo.search.federation.ProviderConfig.PingOption;
+import org.apache.http.conn.params.ConnManagerParams;
+import org.apache.http.conn.params.ConnPerRouteBean;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+
+import com.yahoo.search.federation.ProviderConfig;
+
+/**
+ * A set of parameters for talking to an http backend
+ *
+ * @author bratseth
+ */
+public final class HTTPParameters {
+
+ public static final String RETRIES = "com.yahoo.search.federation.http.retries";
+
+ private boolean frozen=false;
+
+ // All timing parameters below are in milliseconds
+ /** The url request path portion */
+ private String path="/";
+ private int connectionTimeout=2000;
+ private int readTimeout=5000;
+ private boolean persistentConnections=true;
+ private boolean enableProxy = false;
+ private String proxyHost = "localhost";
+ private int proxyPort = 1080;
+ private String method = "GET";
+ private String schema = "http";
+ private String inputEncoding = "utf-8";
+ private String outputEncoding = "utf-8";
+ private int maxTotalConnections=10000;
+ private int maxConnectionsPerRoute=10000;
+ private int socketBufferSizeBytes=-1;
+ private int retries = 1;
+ private int configuredReadTimeout = -1;
+ private int configuredConnectionTimeout = -1;
+ private int connectionPoolTimeout = -1;
+ private String ycaProxy = null;
+ private int ycaPort = 0;
+ private String ycaApplicationId = null;
+ private boolean ycaUseProxy = false;
+ private long ycaTtl = 0L;
+ private long ycaRetry = 0L;
+
+ private PingOption.Enum pingOption = PingOption.NORMAL;
+
+
+ private boolean followRedirects = true;
+
+ public HTTPParameters() {}
+
+ public HTTPParameters(String path) {
+ setPath(path);
+ }
+
+ public HTTPParameters(ProviderConfig providerConfig) {
+ configuredReadTimeout = (int) (providerConfig.readTimeout() * 1000.0d);
+ configuredConnectionTimeout = (int) (providerConfig.connectionTimeout() * 1000.0d);
+ connectionPoolTimeout = (int) (providerConfig.connectionPoolTimeout() * 1000.0d);
+ retries = providerConfig.retries();
+ setPath(providerConfig.path());
+ ycaUseProxy = providerConfig.yca().useProxy();
+ if (ycaUseProxy) {
+ ycaProxy = providerConfig.yca().host();
+ ycaPort = providerConfig.yca().port();
+ }
+ ycaApplicationId = providerConfig.yca().applicationId();
+ ycaTtl = providerConfig.yca().ttl() * 1000L;
+ ycaRetry = providerConfig.yca().retry() * 1000L;
+ followRedirects = providerConfig.followRedirects();
+ pingOption = providerConfig.pingOption();
+ }
+
+ /**
+ * Set the url path to use in queries to this. If the argument is null or empty the path is set to "/".
+ * If a leading "/" is missing, it is added automatically.
+ */
+ public final void setPath(String path) {
+ if (path==null || path.isEmpty()) path="/";
+
+ if (! path.startsWith("/"))
+ path="/" + path;
+ this.path = path;
+ }
+
+ public PingOption.Enum getPingOption() {
+ return pingOption;
+ }
+
+ public void setPingOption(PingOption.Enum pingOption) {
+ Preconditions.checkNotNull(pingOption);
+ ensureNotFrozen();
+ this.pingOption = pingOption;
+ }
+
+ /** Returns the url path. Default is "/". */
+ public String getPath() { return path; }
+
+ public boolean getFollowRedirects() {
+ return followRedirects;
+ }
+
+ public void setFollowRedirects(boolean followRedirects) {
+ ensureNotFrozen();
+ this.followRedirects = followRedirects;
+ }
+
+
+ public void setConnectionTimeout(int connectionTimeout) {
+ ensureNotFrozen();
+ this.connectionTimeout=connectionTimeout;
+ }
+
+ /** Returns the connection timeout in milliseconds. Default is 2000. */
+ public int getConnectionTimeout() { return connectionTimeout; }
+
+ public void setReadTimeout(int readTimeout) {
+ ensureNotFrozen();
+ this.readTimeout=readTimeout;
+ }
+
+ /** Returns the read timeout in milliseconds. Default is 5000. */
+ public int getReadTimeout() { return readTimeout; }
+
+ /**
+ * <b>Note: This is currently largely a noop: Connections are reused even when this is set to true.
+ * The setting will change from sharing connections between threads to only reusing it within a thread
+ * but it is still reused.</b>
+ */
+ public void setPersistentConnections(boolean persistentConnections) {
+ ensureNotFrozen();
+ this.persistentConnections=persistentConnections;
+ }
+
+ /** Returns whether this should use persistent connections. Default is true. */
+ public boolean getPersistentConnections() { return persistentConnections; }
+
+ /** Returns whether proxying should be enabled. Default is false. */
+ public boolean getEnableProxy() { return enableProxy; }
+
+ public void setEnableProxy(boolean enableProxy ) {
+ ensureNotFrozen();
+ this.enableProxy=enableProxy;
+ }
+
+ /** Returns the proxy type to use (if enabled). Default is "http". */
+ public String getProxyType() {
+ return "http";
+ }
+
+ public void setProxyHost(String proxyHost) {
+ ensureNotFrozen();
+ this.proxyHost=proxyHost;
+ }
+
+ /** Returns the proxy host to use (if enabled). Default is "localhost". */
+ public String getProxyHost() { return proxyHost; }
+
+ public void setProxyPort(int proxyPort) {
+ ensureNotFrozen();
+ this.proxyPort=proxyPort;
+ }
+
+ /** Returns the proxy port to use (if enabled). Default is 1080. */
+ public int getProxyPort() { return proxyPort; }
+
+ public void setMethod(String method) {
+ ensureNotFrozen();
+ this.method=method;
+ }
+
+ /** Returns the http method to use. Default is "GET". */
+ public String getMethod() { return method; }
+
+ public void setSchema(String schema) {
+ ensureNotFrozen();
+ this.schema=schema;
+ }
+
+ /** Returns the schema to use. Default is "http". */
+ public String getSchema() { return schema; }
+
+ public void setInputEncoding(String inputEncoding) {
+ ensureNotFrozen();
+ this.inputEncoding=inputEncoding;
+ }
+
+ /** Returns the input encoding. Default is "utf-8". */
+ public String getInputEncoding() { return inputEncoding; }
+
+ public void setOutputEncoding(String outputEncoding) {
+ ensureNotFrozen();
+ this.outputEncoding=outputEncoding;
+ }
+
+ /** Returns the output encoding. Default is "utf-8". */
+ public String getOutputEncoding() { return outputEncoding; }
+
+ /** Make this unmodifiable. Note that any thread synchronization must be done outside this object. */
+ public void freeze() {
+ frozen=true;
+ }
+
+ private void ensureNotFrozen() {
+ if (frozen) throw new IllegalStateException("Cannot modify frozen " + this);
+ }
+
+ /**
+ * Returns the eligible subset of this as a HttpParams snapshot
+ * AND configures the Apache HTTP library with the parameters of this
+ */
+ public HttpParams toHttpParams() {
+ return toHttpParams(connectionTimeout, readTimeout);
+ }
+
+ /**
+ * Returns the eligible subset of this as a HttpParams snapshot
+ * AND configures the Apache HTTP library with the parameters of this
+ */
+ public HttpParams toHttpParams(int connectionTimeout, int readTimeout) {
+ HttpParams params = new BasicHttpParams();
+ // force use of configured value if available
+ if (configuredConnectionTimeout > 0) {
+ HttpConnectionParams.setConnectionTimeout(params, configuredConnectionTimeout);
+ } else {
+ HttpConnectionParams.setConnectionTimeout(params, connectionTimeout);
+ }
+ if (configuredReadTimeout > 0) {
+ HttpConnectionParams.setSoTimeout(params, configuredReadTimeout);
+ } else {
+ HttpConnectionParams.setSoTimeout(params, readTimeout);
+ }
+ if (socketBufferSizeBytes > 0) {
+ HttpConnectionParams.setSocketBufferSize(params, socketBufferSizeBytes);
+ }
+ if (connectionPoolTimeout > 0) {
+ ConnManagerParams.setTimeout(params, connectionPoolTimeout);
+ }
+ ConnManagerParams.setMaxTotalConnections(params, maxTotalConnections);
+ ConnManagerParams.setMaxConnectionsPerRoute(params, new ConnPerRouteBean(maxConnectionsPerRoute));
+ if (retries >= 0) {
+ params.setIntParameter(RETRIES, retries);
+ }
+ params.setParameter("http.protocol.handle-redirects", followRedirects);
+ return params;
+ }
+
+ public int getMaxTotalConnections() {
+ return maxTotalConnections;
+ }
+
+ public void setMaxTotalConnections(int maxTotalConnections) {
+ ensureNotFrozen();
+ this.maxTotalConnections = maxTotalConnections;
+ }
+
+ public int getMaxConnectionsPerRoute() {
+ return maxConnectionsPerRoute;
+ }
+
+ public void setMaxConnectionsPerRoute(int maxConnectionsPerRoute) {
+ ensureNotFrozen();
+ this.maxConnectionsPerRoute = maxConnectionsPerRoute;
+ }
+
+ public int getSocketBufferSizeBytes() {
+ return socketBufferSizeBytes;
+ }
+
+ public void setSocketBufferSizeBytes(int socketBufferSizeBytes) {
+ ensureNotFrozen();
+ this.socketBufferSizeBytes = socketBufferSizeBytes;
+ }
+
+ public int getRetries() {
+ return retries;
+ }
+
+ public void setRetries(int retries) {
+ ensureNotFrozen();
+ this.retries = retries;
+ }
+
+ public String getYcaProxy() {
+ return ycaProxy;
+ }
+
+ public int getYcaPort() {
+ return ycaPort;
+ }
+
+ public String getYcaApplicationId() {
+ return ycaApplicationId;
+ }
+
+ public boolean getYcaUseProxy() {
+ return ycaUseProxy;
+ }
+
+ public long getYcaTtl() {
+ return ycaTtl;
+ }
+
+ public long getYcaRetry() {
+ return ycaRetry;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/HTTPProviderSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPProviderSearcher.java
new file mode 100644
index 00000000000..c2bc6b2196b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPProviderSearcher.java
@@ -0,0 +1,260 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.component.ComponentId;
+import com.yahoo.jdisc.http.CertificateStore;
+import com.yahoo.search.cache.QrBinaryCacheConfig;
+import com.yahoo.search.cache.QrBinaryCacheRegionConfig;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.federation.FederationSearcher;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.statistics.Counter;
+import com.yahoo.statistics.Statistics;
+import com.yahoo.statistics.Value;
+
+import org.apache.http.HttpEntity;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.*;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Superclass of searchers which talks to HTTP backends. Implement a subclass to talk to a backend
+ * over HTTP which is not supported by the platform out of the box.
+ * <p>
+ * Implementations must override one of the <code>unmarshal</code> methods to unmarshal the response.
+ * </p>
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author bratseth
+ */
+public abstract class HTTPProviderSearcher extends HTTPSearcher {
+
+ private final Counter emptyResults;
+ private final Value hitsPerQuery;
+ private final Value responseLatency;
+ private final Counter readTimeouts;
+
+ private final static List<String> excludedSourceProperties = ImmutableList.of("offset", "hits", "provider");
+
+ protected final static Logger log = Logger.getLogger(HTTPProviderSearcher.class.getName());
+
+ /** The name of the cache used (which is just getid().stringValue(), or null if no cache is used */
+ protected String cacheName=null;
+
+ public HTTPProviderSearcher(ComponentId id, List<Connection> connections,String path, Statistics statistics) {
+ this(id,connections,new HTTPParameters(path), statistics);
+ }
+
+ /** Creates a http provider searcher using id.getName as provider name */
+ public HTTPProviderSearcher(ComponentId id, List<Connection> connections, String path,
+ Statistics statistics, CertificateStore certificateStore) {
+ this(id, connections, new HTTPParameters(path), statistics, certificateStore);
+ }
+
+ public HTTPProviderSearcher(ComponentId id, List<Connection> connections, HTTPParameters parameters,
+ Statistics statistics) {
+ this(id, connections, parameters, statistics, new ThrowingCertificateStore());
+ }
+
+ /**
+ * Creates a provider searcher
+ *
+ * @param id the id of this instance
+ * @param connections the connections this will load balance and fail over between
+ * @param parameters the parameters to use when making http calls
+ */
+ public HTTPProviderSearcher(ComponentId id, List<Connection> connections, HTTPParameters parameters,
+ Statistics statistics, CertificateStore certificateStore) {
+ super(id, connections, parameters, statistics, certificateStore);
+ String suffix = "_" + getId().getName().replace('.', '_');
+ hitsPerQuery = new Value("hits_per_query" + suffix, statistics,
+ new Value.Parameters().setLogRaw(false).setNameExtension(false).setLogMean(true));
+ responseLatency = new Value(LOG_LATENCY_START + suffix, statistics,
+ new Value.Parameters().setLogRaw(false).setLogMean(true).setNameExtension(false));
+ emptyResults = new Counter("empty_results" + suffix, statistics, false);
+ readTimeouts = new Counter(LOG_READ_TIMEOUT_PREFIX + suffix, statistics, false);
+ }
+
+ /** @deprecated this method does nothing */
+ @Deprecated
+ protected void configureCache(final QrBinaryCacheConfig cacheConfig,final QrBinaryCacheRegionConfig regionConfig) {
+ }
+
+ /**
+ * Unmarshal the stream by converting it to hits and adding the hits to the given result.
+ * A convenience hook called by the default <code>unmarshal(entity,result).</code>
+ * Override this in subclasses which does not override <code>unmarshal(entity,result).</code>
+ * <p>
+ * This default implementation throws an exception.
+ *
+ * @param stream the stream of data returned
+ * @param contentLength the length of the content in bytes if known, or a negative number if unknown
+ * @param result the result to which unmarshalled data should be added
+ */
+ public void unmarshal(final InputStream stream, long contentLength, final Result result) throws IOException {
+ throw new UnsupportedOperationException("Unmarshal must be implemented by " + this);
+ }
+
+ /**
+ * Unmarshal the result from an http entity. This default implementation calls
+ * <code>unmarshal(entity.getContent(), entity.getContentLength(), result)</code>
+ * (and does some detailed query tracing).
+ *
+ * @param entity the entity containing the data to unmarshal
+ * @param result the result to which unmarshalled data should be added
+ */
+ public void unmarshal(HttpEntity entity,Result result) throws IOException {
+ Query query=result.getQuery();
+ long len = entity.getContentLength();
+ if (query.getTraceLevel()>=4)
+ query.trace("Received " + len + " bytes response in " + this, false, 4);
+ query.trace("Unmarshaling result.", false, 6);
+ unmarshal(entity.getContent(), len, result);
+
+ if (query.getTraceLevel()>=2)
+ query.trace("Handled " + len + " bytes response in " + this, false, 2);
+
+ }
+
+ protected void addNonExcludedSourceProperties(Query query, Map<String, String> queryMap) {
+ Properties sourceProperties = FederationSearcher.getSourceProperties(query);
+ if (sourceProperties != null) {
+ for(Map.Entry<String, Object> entry : sourceProperties.listProperties("").entrySet()) {
+ if (!excludedSourceProperties.contains(entry.getKey())) {
+ queryMap.put(entry.getKey(), entry.getValue().toString());
+ }
+ }
+ }
+ }
+
+ /**
+ * Hook called at the moment the result is returned from this searcher. This default implementation
+ * does <code>return result</code>.
+ *
+ * @param result the result which is to be returned
+ * @param requestMeta the request information hit, or null if none was created (e.g if this was a cache lookup)
+ * @param e the exception caused during execution of this query, or null if none
+ * @return the result which is returned upwards
+ */
+ protected Result inspectAndReturnFinalResult(Result result, Hit requestMeta, Exception e) {
+ return result;
+ }
+
+ private Result statisticsBeforeInspection(Result result,
+ Hit requestMeta, Exception e) {
+ int hitCount = result.getConcreteHitCount();
+ if (hitCount == 0) {
+ emptyResults.increment();
+ }
+ hitsPerQuery.put((double) hitCount);
+
+ if (requestMeta != null) {
+ requestMeta.setField(LOG_HITCOUNT, Integer.valueOf(hitCount));
+ }
+
+ return inspectAndReturnFinalResult(result,
+ requestMeta, e);
+ }
+
+
+ @Override
+ protected void logResponseLatency(long latency) {
+ responseLatency.put((double) latency);
+ }
+
+ @Override
+ public Result search(Query query, Execution execution,Connection connection) {
+ // Create default meta hit for holding logging information
+ Hit requestMeta = createRequestMeta();
+ Result result = new Result(query);
+ result.hits().add(requestMeta);
+ query.trace("Created request information hit", false, 9);
+
+ try {
+ URI uri = getURI(query, requestMeta, connection);
+ if (query.getTraceLevel()>=1)
+ query.trace("Fetching " + uri.toString(), false, 1);
+ long requestStartTime = System.currentTimeMillis();
+
+ HttpEntity entity = getEntity(uri, requestMeta, query);
+
+ // Why should consumeEntity call inspectAndReturnFinalResult itself?
+ // Seems confusing to me.
+ return entity == null
+ ? statisticsBeforeInspection(result, requestMeta, null)
+ : consumeEntity(entity, query, result, requestMeta, requestStartTime);
+
+ } catch (MalformedURLException|URISyntaxException e) {
+ result.hits().addError(createMalformedUrlError(query,e));
+ return statisticsBeforeInspection(result, requestMeta, e);
+ } catch (TimeoutException e) {
+ result.hits().addError(ErrorMessage.createTimeout("No time left for HTTP traffic in "
+ + this
+ + " for " + query + ": " + e.getMessage()));
+ return statisticsBeforeInspection(result, requestMeta, e);
+ } catch (IOException e) {
+ result.hits().addError(ErrorMessage.createBackendCommunicationError(
+ "Error when trying to connect to HTTP backend in " + this
+ + " for " + query + ": " + Exceptions.toMessageString(e)));
+ return statisticsBeforeInspection(result, requestMeta, e);
+ }
+ }
+
+ private Result consumeEntity(HttpEntity entity, Query query, Result result, Hit logHit, long requestStartTime) {
+
+ try {
+ // remove some time from timeout to allow for close calls with return result
+ unmarshal(new TimedHttpEntity(entity, query.getStartTime(), Math.max(1, query.getTimeout() - 10)), result);
+ logHit.setField(LOG_LATENCY_FINISH, System.currentTimeMillis() - requestStartTime);
+ return statisticsBeforeInspection(result, logHit, null);
+ } catch (IOException e) {
+ result.hits().addError(ErrorMessage.createBackendCommunicationError(
+ "Error when trying to consume input in " + this + ": " + Exceptions.toMessageString(e)));
+ return statisticsBeforeInspection(result, logHit, e);
+ } catch (TimeoutException e) {
+ readTimeouts.increment();
+ result.hits().addError(ErrorMessage
+ .createTimeout("Timed out while reading/unmarshaling from backend in "
+ + this + " for " + query
+ + ": " + e.getMessage()));
+ return statisticsBeforeInspection(result, logHit, e);
+ } finally { // TODO: The scope of this finally must be enlarged to release the connection also on errors
+ cleanupHttpEntity(entity);
+ }
+ }
+
+ /**
+ * Returns the key-value pairs that should be added as properties to the request url sent to the service.
+ * Must be overridden in subclasses to add the key-values expected by the service in question, unless
+ * {@link #getURI} (from which this is called) is overridden.
+ * <p>
+ * This default implementation returns the query.properties() prefixed by
+ * "source.[sourceName]" or "property.[propertyName]"
+ * (by calling {@link #addNonExcludedSourceProperties}).
+ */
+ @Override
+ public Map<String,String> getQueryMap(Query query) {
+ Map<String,String> queryMap = super.getQueryMap(query);
+ addNonExcludedSourceProperties(query, queryMap);
+ return queryMap;
+ }
+
+ /**
+ * @deprecated the cache key is ignored as there is no built-in caching support
+ */
+ @Deprecated
+ public abstract Map<String, String> getCacheKey(Query q);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/HTTPSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPSearcher.java
new file mode 100644
index 00000000000..65ce7b3647c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/HTTPSearcher.java
@@ -0,0 +1,958 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import com.google.inject.Inject;
+import com.yahoo.component.ComponentId;
+import com.yahoo.jdisc.http.CertificateStore;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.Ping;
+import com.yahoo.prelude.Pong;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.cluster.ClusterSearcher;
+import com.yahoo.search.federation.ProviderConfig.PingOption;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.statistics.Counter;
+import com.yahoo.statistics.Statistics;
+import com.yahoo.text.Utf8;
+
+import org.apache.http.*;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.HttpRequestRetryHandler;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.conn.ClientConnectionManager;
+import org.apache.http.conn.ConnectTimeoutException;
+import org.apache.http.conn.params.ConnManagerParams;
+import org.apache.http.conn.params.ConnRoutePNames;
+import org.apache.http.conn.routing.HttpRoutePlanner;
+import org.apache.http.conn.scheme.PlainSocketFactory;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.conn.scheme.SchemeRegistry;
+import org.apache.http.conn.ssl.SSLSocketFactory;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.impl.conn.DefaultHttpRoutePlanner;
+import org.apache.http.impl.conn.SingleClientConnManager;
+import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
+import org.apache.http.params.HttpParams;
+import org.apache.http.params.HttpProtocolParams;
+import org.apache.http.protocol.BasicHttpContext;
+import org.apache.http.protocol.ExecutionContext;
+import org.apache.http.protocol.HttpContext;
+import org.apache.http.protocol.HttpRequestExecutor;
+import org.apache.http.util.EntityUtils;
+
+import javax.net.ssl.SSLHandshakeException;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.io.UnsupportedEncodingException;
+import java.net.*;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Generic superclass of searchers making connections to some HTTP service. This
+ * supports clustered connections - a list of alternative servers may be given,
+ * requests will be hashed across these and failed over in case some are down.
+ * <p>
+ * This simply provides some utility methods for working with http connections
+ * and implements ping against the service.
+ *
+ * <p>This searcher contains code from the Apache httpcomponents client library,
+ * licensed to the Apache Software Foundation under the Apache License, Version
+ * 2.0. Please refer to http://www.apache.org/licenses/LICENSE-2.0 for details.
+ *
+ * <p>This class automatically adds a meta hit containing latency and other
+ * meta information about the obtained HTTP data using createRequestMeta().
+ * The fields available in the hit are:</p>
+ *
+ * <dl><dt>
+ * HTTPSearcher.LOG_LATENCY_START
+ * <dd>
+ * The latency of the external provider answering a request.
+ * <dt>
+ * HTTPSearcher.LOG_LATENCY_FINISH
+ * <dd>
+ * Total time of the HTTP traffic, but also decoding of the data, as this
+ * happens at the same time.
+ * <dt>
+ * HTTPSearcher.LOG_HITCOUNT
+ * <dd>
+ * Number of concrete hits in the result returned by this provider.
+ * <dt>
+ * HTTPSearcher.LOG_URI
+ * <dd>
+ * The complete URI used for external service.
+ * <dt>
+ * HTTPSearcher.LOG_SCHEME
+ * <dd>
+ * The scheme of the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_HOST
+ * <dd>
+ * The host used for the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_PORT
+ * <dd>
+ * The port used for the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_PATH
+ * <dd>
+ * Path element of the request URI sent.
+ * <dt>
+ * HTTPSearcher.LOG_STATUS
+ * <dd>
+ * Status code of the HTTP response.
+ * <dt>
+ * HTTPSearcher.LOG_PROXY_TYPE
+ * <dd>
+ * The proxy type used, if any. Default is "http".
+ * <dt>
+ * HTTPSearcher.LOG_PROXY_HOST
+ * <dd>
+ * The proxy host, if any.
+ * <dt>
+ * HTTPSearcher.LOG_PROXY_PORT
+ * <dd>
+ * The proxy port, if any.
+ * <dt>
+ * HTTPSearcher.LOG_HEADER_PREFIX prepended to request header field name
+ * <dd>
+ * The content of any additional request header fields.
+ * <dt>
+ * HTTPSearcher.LOG_RESPONSE_HEADER_PREFIX prepended to response header field name
+ * <dd>
+ * The content of any additional response header fields.
+ * </dl>
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+public abstract class HTTPSearcher extends ClusterSearcher<Connection> {
+
+ protected static final String YCA_HTTP_HEADER = "Yahoo-App-Auth";
+
+ private static final Charset iso8859Charset = Charset.forName("ISO-8859-1");
+
+ // Logging field name constants
+ public static final String LOG_PATH = "path";
+ public static final String LOG_PORT = "port";
+ public static final String LOG_HOST = "host";
+ public static final String LOG_IP_ADDRESS = "ip_address";
+ public static final String IP_ADDRESS_UNKNOWN = "unknown";
+
+ public static final String LOG_SCHEME = "scheme";
+ public static final String LOG_URI = "uri";
+ public static final String LOG_PROXY_PORT = "proxy_port";
+ public static final String LOG_PROXY_HOST = "proxy_host";
+ public static final String LOG_PROXY_TYPE = "proxy_type";
+ public static final String LOG_STATUS = "status";
+ public static final String LOG_LATENCY_FINISH = "latency_finish";
+ public static final String LOG_LATENCY_START = "latency_start";
+ public static final String LOG_LATENCY_CONNECT = "latency_connect";
+ public static final String LOG_QUERY_PARAM_PREFIX = "query_param_";
+ public static final String LOG_HEADER_PREFIX = "header_";
+ public static final String LOG_RESPONSE_HEADER_PREFIX = "response_header_";
+ public static final String LOG_HITCOUNT = "hit_count";
+ public static final String LOG_CONNECT_TIMEOUT_PREFIX = "connect_timeout_";
+ public static final String LOG_READ_TIMEOUT_PREFIX = "read_timeout_";
+
+ protected final Logger log = Logger.getLogger(HTTPSearcher.class.getName());
+
+ /** The HTTP parameters to use. Assigned in the constructor */
+ private HTTPParameters httpParameters;
+
+ private final Counter connectTimeouts;
+
+ /** Whether to use certificates */
+ protected boolean useCertificate = false;
+
+ private final CertificateStore certificateStore;
+
+ /** The (optional) YCA application ID. */
+ private String ycaApplicationId = null;
+
+ /** The (optional) YCA proxy */
+ protected HttpHost ycaProxy = null;
+
+ /** YCA cache TTL in ms */
+ private long ycaTtl = 0L;
+
+ /** YCA retry rate in the cache if no cert is found, in ms */
+ private long ycaRetry = 0L;
+
+ /** Set at construction if this is using persistent connections */
+ private ClientConnectionManager sharedConnectionManager = null;
+
+ /** Set at construction if using non-persistent connections */
+ private ThreadLocal<SingleClientConnManager> singleClientConnManagerThreadLocal = null;
+
+ private static final SchemeRegistry schemeRegistry = new SchemeRegistry();
+
+ static {
+ schemeRegistry.register(new Scheme("http", PlainSocketFactory
+ .getSocketFactory(), 80));
+ schemeRegistry.register(new Scheme("https", SSLSocketFactory
+ .getSocketFactory(), 443));
+ }
+
+ public HTTPSearcher(ComponentId componentId, List<Connection> connections,String path, Statistics statistics) {
+ this(componentId, connections, new HTTPParameters(path), statistics, new ThrowingCertificateStore());
+ }
+
+ /** Creates a http searcher with default connection and read timeouts (currently 2 and 5s respectively) */
+ public HTTPSearcher(ComponentId componentId, List<Connection> connections,String path, Statistics statistics,
+ CertificateStore certificateStore) {
+ this(componentId, connections, new HTTPParameters(path), statistics, certificateStore);
+ }
+
+ public HTTPSearcher(ComponentId componentId, List<Connection> connections, HTTPParameters parameters,
+ Statistics statistics) {
+ this(componentId, connections, parameters, statistics, new ThrowingCertificateStore());
+ }
+ /**
+ * Creates a http searcher
+ *
+ * @param componentId the id of this instance
+ * @param connections the connections to establish to the backend nodes
+ * @param parameters the http parameters to use. This object will be frozen if it isn't already
+ */
+ @Inject
+ public HTTPSearcher(ComponentId componentId, List<Connection> connections, HTTPParameters parameters,
+ Statistics statistics, CertificateStore certificateStore) {
+ super(componentId,connections,false);
+ String suffix = "_" + getId().getName().replace('.', '_');
+
+ connectTimeouts = new Counter(LOG_CONNECT_TIMEOUT_PREFIX + suffix, statistics, false);
+
+ parameters.freeze();
+ this.httpParameters = parameters;
+ this.certificateStore = certificateStore;
+
+ if (parameters.getPersistentConnections()) {
+ HttpParams params=parameters.toHttpParams();
+ HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
+ ConnManagerParams.setTimeout(params, 10);
+ sharedConnectionManager = new ThreadSafeClientConnManager(params, schemeRegistry);
+ Thread connectionPurgerThread = new Thread(() -> {
+ //this is the default value in yahoo jvm installations
+ long DNSTTLSec = 120;
+ while (true) {
+ try {
+ Thread.sleep(DNSTTLSec * 1000);
+ if (sharedConnectionManager == null)
+ continue;
+
+ sharedConnectionManager.closeExpiredConnections();
+ DNSTTLSec = Long.valueOf(java.security.Security
+ .getProperty("networkaddress.cache.ttl"));
+ //No DNS TTL, no need to close idle connections
+ if (DNSTTLSec <= 0) {
+ DNSTTLSec = 120;
+ continue;
+ }
+ sharedConnectionManager.closeIdleConnections(2 * DNSTTLSec, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ return;
+ } catch (NumberFormatException e) {
+ continue;
+ }
+ }
+ });
+ connectionPurgerThread.setDaemon(true);
+ connectionPurgerThread.start();
+
+ }
+ else {
+ singleClientConnManagerThreadLocal =new ThreadLocal<>();
+ }
+
+ initializeYCA(httpParameters, certificateStore);
+ }
+
+ /**
+ * Initialize YCA certificate and proxy if they have been set to non-null,
+ * non-empty values. It will wrap thrown exceptions from the YCA layer into
+ * RuntimeException and propagate them.
+ */
+ private void initializeYCA(HTTPParameters parameters, CertificateStore certificateStore) {
+ String applicationId = parameters.getYcaApplicationId();
+ String proxy = parameters.getYcaProxy();
+ int port = parameters.getYcaPort();
+ long ttl = parameters.getYcaTtl();
+ long retry = parameters.getYcaRetry();
+
+ if (applicationId != null && !applicationId.trim().isEmpty()) {
+ initializeCertificate(applicationId, ttl, retry, certificateStore);
+ }
+
+ if (parameters.getYcaUseProxy()) {
+ initializeProxy(proxy, port);
+ }
+ }
+
+ /** Returns the HTTP parameters used in this. This is always frozen */
+ public HTTPParameters getParameters() { return httpParameters; }
+
+ /**
+ * Returns the key-value pairs that should be added as properties to the request url sent to the service.
+ * Must be overridden in subclasses to add the key-values expected by the service in question, unless
+ * {@link #getURI} (from which this is called) is overridden.
+ * <p>
+ * This default implementation returns an empty LinkedHashMap.
+ */
+ public Map<String,String> getQueryMap(Query query) {
+ return new LinkedHashMap<>();
+ }
+
+ /**
+ * Initialize the YCA certificate.
+ * This will warn but not throw if certificates could not be loaded, as the certificates
+ * are external state which can fail independently.
+ */
+ private void initializeCertificate(String applicationId, long ttl, long retry, CertificateStore certificateStore) {
+ try {
+ // get the certificate, i.e. init the cache and check integrity
+ String certificate = certificateStore.getCertificate(applicationId, ttl, retry);
+ if (certificate == null) {
+ getLogger().log(LogLevel.WARNING, "No certificate found for application '" + applicationId + "'");
+ return;
+ }
+
+ this.useCertificate = true;
+ this.ycaApplicationId = applicationId;
+ this.ycaTtl = ttl;
+ this.ycaRetry = retry;
+ getLogger().log(LogLevel.CONFIG, "Got certificate: " + certificate);
+ }
+ catch (Exception e) {
+ getLogger().log(LogLevel.WARNING,"Exception while initializing certificate for application '" +
+ applicationId + "' in " + this, e);
+ }
+ }
+
+ /**
+ * Initialize the YCA proxy setting.
+ */
+ private void initializeProxy(String host, int port) {
+ ycaProxy = new HttpHost(host, port);
+ getLogger().log(LogLevel.CONFIG,"Proxy is configured; will use proxy: " + ycaProxy);
+ }
+
+ /**
+ * Same a {@code getURI(query, offset, hits, null)}.
+ * @see #getURI(Query, Hit, Connection)
+ */
+ protected URI getURI(Query query,Connection connection) throws MalformedURLException, URISyntaxException {
+ Hit requestMeta;
+ try {
+ requestMeta = (Hit) query.properties().get(HTTPClientSearcher.REQUEST_META_CARRIER);
+ } catch (ClassCastException e) {
+ requestMeta = null;
+ }
+ return getURI(query, requestMeta, connection);
+ }
+
+ /**
+ * Creates the URI for a query.
+ * Populates the {@code requestMeta} meta hit with the created URI HTTP properties.
+ *
+ * @param requestMeta a meta hit that holds logging information about this request (may be {@code null}).
+ */
+ protected URI getURI(Query query, Hit requestMeta, Connection connection)
+ throws MalformedURLException, URISyntaxException {
+ StringBuilder parameters = new StringBuilder();
+
+ Map<String, String> queries = getQueryMap(query);
+ if (queries.size() > 0) {
+ Iterator<Map.Entry<String, String>> mapIterator = queries.entrySet().iterator();
+ parameters.append("?");
+ try {
+ Map.Entry<String, String> entry;
+ while (mapIterator.hasNext()) {
+ entry = mapIterator.next();
+
+ if (requestMeta != null)
+ requestMeta.setField(LOG_QUERY_PARAM_PREFIX
+ + entry.getKey(), entry.getValue());
+
+ parameters.append(entry.getKey() + "=" + URLEncoder.encode(entry.getValue(),
+ httpParameters.getInputEncoding()));
+ if (mapIterator.hasNext()) {
+ parameters.append("&");
+ }
+ }
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException("Unknown input encoding set in " + this, e);
+ }
+ }
+
+ URI uri = new URL(httpParameters.getSchema(), connection.getHost(),
+ connection.getPort(), getPath() + parameters.toString()).toURI();
+ if (requestMeta != null) {
+ requestMeta.setField(LOG_URI, uri.toString());
+ requestMeta.setField(LOG_SCHEME, uri.getScheme());
+ requestMeta.setField(LOG_HOST, uri.getHost());
+ requestMeta.setField(LOG_PORT, uri.getPort());
+ requestMeta.setField(LOG_PATH, uri.getPath());
+ }
+ return uri;
+ }
+
+ /**
+ * Called by getURI() to get the path of the URI for the external service.
+ * The default implementation returns httpParameters.getPath(); subclasses
+ * which only wants to override the path from httpParameters may use this
+ * method instead of overriding all of getURI().
+ *
+ * @return the path to use for getURI
+ */
+ protected String getPath() {
+ return httpParameters.getPath();
+ }
+
+ /**
+ * The URI that is used to check if the provider is up or down. This will again be used in the
+ * checkPing method by checking that we get a response that has a good status code (below 300). If better
+ * validation than just status code checking is needed, override the checkPing method.
+ */
+ protected URI getPingURI(Connection connection) throws MalformedURLException, URISyntaxException {
+ return new URL(httpParameters.getSchema(),connection.getHost(),connection.getPort(),getPingPath()).toURI();
+ }
+
+ /**
+ * Called by getPingURI() to get the path of the URI for pinging the
+ * external service. The default implementation returns
+ * httpParameters.getPath(); subclasses which only wants to override the
+ * path from httpParameters may use this method instead of overriding all of
+ * getPingURI().
+ *
+ * @return the path to use for getPingURI
+ */
+ protected String getPingPath() {
+ return httpParameters.getPath();
+ }
+
+ /**
+ * Checks if the response is valid.
+ * @param response The response from the ping request
+ * @param pong The pong result to return back to the calling method. This method
+ * will add an error to the pong result (using addError) if the status of the HTTP response is 300 or above.
+ */
+ protected void checkPing(HttpResponse response, Pong pong) {
+ if (response.getStatusLine().getStatusCode() >= 300) {
+ pong.addError(com.yahoo.search.result.ErrorMessage.createBackendCommunicationError(
+ "Got error " + response.getStatusLine().getStatusCode()
+ + " when contacting backend")
+ );
+ }
+ }
+
+ /**
+ * Pinging in HTTPBackend is done by creating a PING uri from http://host:port/path.
+ * If this returns a status that is below 300, the ping is considered good.
+ *
+ * If another uri is needed for pinging, reimplement getPingURI.
+ *
+ * Override either this method to change how ping
+ */
+ @Override
+ public Pong ping(Ping ping, Connection connection) {
+ URI uri = null;
+ Pong pong = new Pong();
+ HttpResponse response = null;
+
+ if (httpParameters.getPingOption() == PingOption.DISABLE)
+ return pong;
+
+ try {
+ uri = getPingURI(connection);
+ if (uri == null)
+ pong.addError(ErrorMessage.createIllegalQuery("Ping uri is null"));
+ if (uri.getHost()==null) {
+ pong.addError(ErrorMessage.createIllegalQuery("Ping uri has no host"));
+ uri=null;
+ }
+ } catch (MalformedURLException | URISyntaxException e) {
+ pong.addError(ErrorMessage.createIllegalQuery("Malformed ping uri '" + uri + "': " +
+ Exceptions.toMessageString(e)));
+ } catch (RuntimeException e) {
+ log.log(Level.WARNING,"Unexpected exception while attempting to ping " + connection + " using uri '" + uri + "'",e);
+ pong.addError(ErrorMessage.createIllegalQuery("Unexpected problem with ping uri '" + uri + "': " +
+ Exceptions.toMessageString(e)));
+ }
+
+ if (uri == null) return pong;
+ pong.setPingInfo("using uri '" + uri + "'");
+
+ try {
+ response = getPingResponse(uri, ping);
+ checkPing(response, pong);
+ } catch (IOException e) {
+ //We do not have a valid ping
+ pong.addError(ErrorMessage.createBackendCommunicationError(
+ "Exception thrown when pinging with url '" + uri + "': " + Exceptions.toMessageString(e)));
+ } catch (TimeoutException e) {
+ pong.addError(ErrorMessage.createTimeout("Timeout for ping "
+ + uri + " in " + this + ": " + e.getMessage()));
+ } catch (RuntimeException e) {
+ log.log(Level.WARNING,"Unexpected exception while attempting to ping " + connection + " using uri '" + uri + "'",e);
+ pong.addError(ErrorMessage.createIllegalQuery("Unexpected problem with ping uri '" + uri + "': " +
+ Exceptions.toMessageString(e)));
+ } finally {
+ if (response != null) {
+ cleanupHttpEntity(response.getEntity());
+ }
+ }
+
+ return pong;
+ }
+
+ private HttpResponse getPingResponse(URI uri, Ping ping) throws IOException {
+ long timeLeft = ping.getTimeout();
+ int connectionTimeout = (int) (timeLeft / 4L);
+ int readTimeout = (int) (timeLeft * 3L / 4L);
+
+ Map<String, String> requestHeaders = null;
+ if (httpParameters.getPingOption() == PingOption.YCA)
+ requestHeaders = generateYCAHeaders();
+
+ return getResponse(uri, null, requestHeaders, null, connectionTimeout, readTimeout);
+ }
+
+ /**
+ * Same a {@code getEntity(uri, null)}.
+ * @param uri resource to fetch
+ * @param query the originating query
+ * @throws TimeoutException If query.timeLeft() equal to or lower than 0
+ */
+ protected HttpEntity getEntity(URI uri, Query query) throws IOException{
+ return getEntity(uri, null, query);
+ }
+
+
+ /**
+ * Gets the HTTP entity that holds the response contents.
+ * @param uri the request URI.
+ * @param requestMeta a meta hit that holds logging information about this request (may be {@code null}).
+ * @param query the originating query
+ * @return the http entity, or null if none
+ * @throws java.io.IOException Whenever HTTP status code is in the 300 or higher range.
+ * @throws TimeoutException If query.timeLeft() equal to or lower than 0
+ */
+ protected HttpEntity getEntity(URI uri, Hit requestMeta, Query query) throws IOException {
+ if (query.getTimeLeft() <= 0) {
+ throw new TimeoutException("No time left for querying external backend.");
+ }
+ HttpResponse response = getResponse(uri, requestMeta, query);
+ StatusLine statusLine = response.getStatusLine();
+
+ // Logging
+ if (requestMeta != null) {
+ requestMeta.setField(LOG_STATUS, statusLine.getStatusCode());
+ for (HeaderIterator headers = response.headerIterator(); headers.hasNext(); ) {
+ Header h = headers.nextHeader();
+ requestMeta.setField(LOG_RESPONSE_HEADER_PREFIX + h.getName(), h.getValue());
+ }
+ }
+
+ if (statusLine.getStatusCode() >= 300) {
+ HttpEntity entity = response.getEntity();
+ String message = createServerReporterErrorMessage(statusLine, entity);
+ cleanupHttpEntity(response.getEntity());
+ throw new IOException(message);
+ }
+
+ return response.getEntity();
+ }
+
+ private String createServerReporterErrorMessage(StatusLine statusLine, HttpEntity entity) {
+ String message = "Error when trying to connect to HTTP backend: "
+ + statusLine.getStatusCode() + " : " + statusLine.getReasonPhrase();
+
+ try {
+ if (entity != null) {
+ message += "(Message = " + EntityUtils.toString(entity) + ")";
+ }
+ } catch (Exception e) {
+ log.log(LogLevel.WARNING, "Could not get message.", e);
+ }
+
+ return message;
+ }
+
+ /**
+ * Creates a meta hit dedicated to holding logging information. This hit has
+ * the 'logging:[searcher's ID]' type.
+ */
+ protected Hit createRequestMeta() {
+ Hit requestMeta = new Hit("logging:" + getId().toString());
+ requestMeta.setMeta(true);
+ requestMeta.types().add("logging");
+ return requestMeta;
+ }
+
+ protected void cleanupHttpEntity(HttpEntity entity) {
+ if (entity == null) return;
+
+ try {
+ entity.consumeContent();
+ } catch (IOException e) {
+ // It is ok if do not consume it, the resource will be freed after
+ // timeout.
+ // But log it just in case.
+ log.log(LogLevel.getVespaLogLevel(LogLevel.DEBUG),
+ "Not able to consume after processing: " + Exceptions.toMessageString(e));
+ }
+ }
+
+ /**
+ * Same as {@code getResponse(uri, null)}.
+ */
+ protected HttpResponse getResponse(URI uri, Query query) throws IOException{
+ return getResponse(uri, null, query);
+ }
+
+ /**
+ * Executes an HTTP request and gets the response.
+ * @param uri the request URI.
+ * @param requestMeta a meta hit that holds logging information about this request (may be {@code null}).
+ * @param query the originating query, used to calculate timeouts
+ */
+ protected HttpResponse getResponse(URI uri, Hit requestMeta, Query query) throws IOException {
+ long timeLeft = query.getTimeLeft();
+ int connectionTimeout = (int) (timeLeft / 4L);
+ int readTimeout = (int) (timeLeft * 3L / 4L);
+ connectionTimeout = connectionTimeout <= 0 ? 1 : connectionTimeout;
+ readTimeout = readTimeout <= 0 ? 1 : readTimeout;
+ HttpEntity reqEntity = getRequestEntity(query, requestMeta);
+ Map<String, String> reqHeaders = getRequestHeaders(query, requestMeta);
+ if ((reqEntity == null) && (reqHeaders == null)) {
+ return getResponse(uri, requestMeta, connectionTimeout, readTimeout);
+ } else {
+ return getResponse(uri, reqEntity, reqHeaders, requestMeta, connectionTimeout, readTimeout);
+ }
+ }
+
+ /**
+ * Returns the set of headers to be passed in the http request to provider backend. The default
+ * implementation returns null, unless YCA is in use. If YCA is used, it will return a map
+ * only containing the needed YCA headers.
+ */
+ protected Map<String, String> getRequestHeaders(Query query, Hit requestMeta) {
+ if (useCertificate) {
+ return generateYCAHeaders();
+ }
+ return null;
+ }
+
+ /**
+ * Returns the HTTP request entity to use when making the request for this query.
+ * This default implementation returns null.
+ *
+ * <p> Do return a repeatable entity if HTTP retry is active.
+ *
+ * @return the http request entity to use, or null to use the default entity
+ */
+ protected HttpEntity getRequestEntity(Query query, Hit requestMeta) {
+ return null;
+ }
+
+ /**
+ * Executes an HTTP request and gets the response.
+ * @param uri the request URI.
+ * @param requestMeta a meta hit that holds logging information about this request (may be {@code null}).
+ * @param connectionTimeout how long to wait for getting a connection
+ * @param readTimeout timeout for reading HTTP data
+ */
+ protected HttpResponse getResponse(URI uri, Hit requestMeta, int connectionTimeout, int readTimeout)
+ throws IOException {
+ return getResponse(uri, null, null, requestMeta, connectionTimeout, readTimeout);
+ }
+
+
+ /**
+ * Executes an HTTP request and gets the response.
+ * @param uri the request URI.
+ * @param requestMeta a meta hit that holds logging information about this request (may be {@code null}).
+ * @param connectionTimeout how long to wait for getting a connection
+ * @param readTimeout timeout for reading HTTP data
+ */
+ protected HttpResponse getResponse(URI uri, HttpEntity reqEntity,
+ Map<String, String> reqHeaders, Hit requestMeta,
+ int connectionTimeout, int readTimeout) throws IOException {
+
+ HttpParams httpParams = httpParameters.toHttpParams(connectionTimeout, readTimeout);
+ HttpClient httpClient = createClient(httpParams);
+ long start = 0L;
+ HttpUriRequest request;
+ if (httpParameters.getEnableProxy() && "http".equals(httpParameters.getProxyType())) {
+ HttpHost proxy = new HttpHost(httpParameters.getProxyHost(),
+ httpParameters.getProxyPort(), httpParameters.getProxyType());
+ httpClient.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
+ // Logging
+ if (requestMeta != null) {
+ requestMeta.setField(LOG_PROXY_TYPE, httpParameters.getProxyType());
+ requestMeta.setField(LOG_PROXY_HOST, httpParameters.getProxyHost());
+ requestMeta.setField(LOG_PROXY_PORT, httpParameters.getProxyPort());
+ }
+ }
+ if (reqEntity == null) {
+ request = createRequest(httpParameters.getMethod(), uri);
+ } else {
+ request = createRequest(httpParameters.getMethod(), uri, reqEntity);
+ }
+
+ if (reqHeaders != null) {
+ for (Entry<String, String> entry : reqHeaders.entrySet()) {
+ if (entry.getValue() == null || isAscii(entry.getValue())) {
+ request.addHeader(entry.getKey(), entry.getValue());
+ } else {
+ byte[] asBytes = Utf8.toBytes(entry.getValue());
+ String asLyingString = new String(asBytes, 0, asBytes.length, iso8859Charset);
+ request.addHeader(entry.getKey(), asLyingString);
+ }
+ }
+ }
+
+ // Logging
+ if (requestMeta != null) {
+ for (HeaderIterator headers = request.headerIterator(); headers.hasNext();) {
+ Header h = headers.nextHeader();
+ requestMeta.setField(LOG_HEADER_PREFIX + h.getName(), h.getValue());
+ }
+ start = System.currentTimeMillis();
+ }
+
+ HttpResponse response;
+
+ try {
+ HttpContext context = new BasicHttpContext();
+ response = httpClient.execute(request, context);
+
+ if (requestMeta != null) {
+ requestMeta.setField(LOG_IP_ADDRESS, getIpAddress(context));
+ }
+ } catch (ConnectTimeoutException e) {
+ connectTimeouts.increment();
+ throw e;
+ }
+
+ // Logging
+ long latencyStart = System.currentTimeMillis() - start;
+ if (requestMeta != null) {
+ requestMeta.setField(LOG_LATENCY_START, latencyStart);
+ }
+ logResponseLatency(latencyStart);
+ return response;
+ }
+
+ private String getIpAddress(HttpContext context) {
+ HttpConnection connection = (HttpConnection) context.getAttribute(ExecutionContext.HTTP_CONNECTION);
+ if (connection instanceof HttpInetConnection) {
+ InetAddress address = ((HttpInetConnection) connection).getRemoteAddress();
+ String hostAddress = address.getHostAddress();
+ return hostAddress == null ?
+ IP_ADDRESS_UNKNOWN:
+ hostAddress;
+ } else {
+ getLogger().log(LogLevel.DEBUG, "Unexpected connection type: " + connection.getClass().getName());
+ return IP_ADDRESS_UNKNOWN;
+ }
+ }
+
+ private boolean isAscii(String value) {
+ char[] scanBuffer = new char[value.length()];
+ value.getChars(0, value.length(), scanBuffer, 0);
+ for (char c: scanBuffer)
+ if (c > 127) return false;
+ return true;
+ }
+
+ protected void logResponseLatency(long latency) { }
+
+ /**
+ * Creates a http client for one request. Override to customize the client
+ * to use, e.g for testing. This default implementation will add the YCA
+ * proxy to params if is necessary, and then do
+ * <code>return new SearcherHttpClient(getConnectionManager(params), params);</code>
+ */
+ protected HttpClient createClient(HttpParams params) {
+ if (ycaProxy != null) {
+ params.setParameter(ConnRoutePNames.DEFAULT_PROXY, ycaProxy);
+ }
+ return new SearcherHttpClient(getConnectionManager(params), params);
+ }
+
+ /**
+ * Creates a HttpRequest. Override to customize the request.
+ * This default implementation does <code>return new HttpRequest(method,uri);</code>
+ */
+ protected HttpUriRequest createRequest(String method,URI uri) {
+ return createRequest(method, uri, null);
+ }
+
+ /**
+ * Creates a HttpRequest. Override to customize the request.
+ * This default implementation does <code>return new HttpRequest(method,uri);</code>
+ */
+ protected HttpUriRequest createRequest(String method,URI uri, HttpEntity entity) {
+ return new SearcherHttpRequest(method,uri);
+ }
+
+ /** Get a connection manager which may be used safely from this thread */
+ protected ClientConnectionManager getConnectionManager(HttpParams params) {
+ if (sharedConnectionManager != null) {// We are using shared connections
+ return sharedConnectionManager;
+ } else {
+ SingleClientConnManager singleClientConnManager = singleClientConnManagerThreadLocal.get();
+ if (singleClientConnManager == null) {
+ singleClientConnManager = new SingleClientConnManager(params, schemeRegistry);
+ singleClientConnManagerThreadLocal.set(singleClientConnManager);
+ }
+ return singleClientConnManager;
+ }
+ }
+
+ /** Utility method for creating error messages when a url is incorrect */
+ protected ErrorMessage createMalformedUrlError(Query query,Exception e) {
+ return ErrorMessage.createErrorInPluginSearcher("Malformed url in " + this + " for " + query +
+ ": " + Exceptions.toMessageString(e));
+ }
+
+ private Map<String, String> generateYCAHeaders() {
+ Map<String, String> headers = new HashMap<>();
+ String certificate = certificateStore.getCertificate(ycaApplicationId, ycaTtl, ycaRetry);
+ headers.put(YCA_HTTP_HEADER, certificate);
+ return headers;
+ }
+
+ protected static class SearcherHttpClient extends DefaultHttpClient {
+
+ private final int retries;
+
+ public SearcherHttpClient(final ClientConnectionManager conman, final HttpParams params) {
+ super(conman, params);
+ retries = params.getIntParameter(HTTPParameters.RETRIES, 1);
+ addRequestInterceptor((request, context) -> {
+ if (!request.containsHeader("Accept-Encoding")) {
+ request.addHeader("Accept-Encoding", "gzip");
+ }
+ });
+ addResponseInterceptor((response, context) -> {
+ HttpEntity entity = response.getEntity();
+ if (entity == null) return;
+ Header ceheader = entity.getContentEncoding();
+ if (ceheader == null) return;
+ for (HeaderElement codec : ceheader.getElements()) {
+ if (codec.getName().equalsIgnoreCase("gzip")) {
+ response.setEntity(new GzipDecompressingEntity(response.getEntity()));
+ return;
+ }
+ }
+ });
+ }
+
+ @Override
+ protected HttpRequestExecutor createRequestExecutor() {
+ return new HttpRequestExecutor();
+ }
+
+ @Override
+ protected HttpRoutePlanner createHttpRoutePlanner() {
+ return new DefaultHttpRoutePlanner(getConnectionManager().getSchemeRegistry());
+ }
+
+ @Override
+ protected HttpRequestRetryHandler createHttpRequestRetryHandler() {
+ return new SearcherHttpRequestRetryHandler(retries);
+ }
+ }
+
+ /** A retry handler which avoids retrying forever on errors misclassified as transient */
+ private static class SearcherHttpRequestRetryHandler implements HttpRequestRetryHandler {
+ private final int retries;
+
+ public SearcherHttpRequestRetryHandler(int retries) {
+ this.retries = retries;
+ }
+
+ @Override
+ public boolean retryRequest(IOException e, int executionCount, HttpContext httpContext) {
+ if (e == null) {
+ throw new IllegalArgumentException("Exception parameter may not be null");
+ }
+ if (executionCount > retries) {
+ return false;
+ }
+ if (e instanceof NoHttpResponseException) {
+ // Retry if the server dropped connection on us
+ return true;
+ }
+ if (e instanceof InterruptedIOException) {
+ // Timeout from federation layer
+ return false;
+ }
+ if (e instanceof UnknownHostException) {
+ // Unknown host
+ return false;
+ }
+ if (e instanceof SSLHandshakeException) {
+ // SSL handshake exception
+ return false;
+ }
+ return true;
+ }
+
+
+ }
+
+ private static class SearcherHttpRequest extends HttpRequestBase {
+ String method;
+
+ public SearcherHttpRequest(String method, final URI uri) {
+ super();
+ this.method = method;
+ setURI(uri);
+ }
+
+ @Override
+ public String getMethod() {
+ return method;
+ }
+ }
+
+ /**
+ * Only for testing.
+ */
+ public void shutdownConnectionManagers() {
+ ClientConnectionManager manager;
+ if (sharedConnectionManager != null) {
+ manager = sharedConnectionManager;
+ } else {
+ manager = singleClientConnManagerThreadLocal.get();
+ }
+ if (manager != null) {
+ manager.shutdown();
+ }
+ }
+
+ protected static final class ThrowingCertificateStore implements CertificateStore {
+
+ @Override
+ public String getCertificate(String key, long ttl, long retry) {
+ throw new UnsupportedOperationException("A certificate store is not available");
+ }
+
+ }
+
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/TimedHttpEntity.java b/container-search/src/main/java/com/yahoo/search/federation/http/TimedHttpEntity.java
new file mode 100644
index 00000000000..9d89a318c32
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/TimedHttpEntity.java
@@ -0,0 +1,88 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+
+/**
+ * Wrapper for adding timeout to an HttpEntity instance.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class TimedHttpEntity implements HttpEntity {
+ /**
+ * The wrapped entity. Never null.
+ */
+ private final HttpEntity entity;
+ private final long startTime;
+ private final long timeout;
+
+ public TimedHttpEntity(HttpEntity entity, long startTime, long timeout) {
+ if (entity == null) {
+ throw new IllegalArgumentException("TimedHttpEntity cannot be instantiated with null HttpEntity.");
+ }
+ this.entity = entity;
+ this.startTime = startTime;
+ this.timeout = timeout;
+ }
+
+
+ @Override
+ public InputStream getContent() throws IOException, IllegalStateException {
+ InputStream content = entity.getContent();
+ if (content == null) {
+ return null;
+ } else {
+ return new TimedStream(content, startTime, timeout);
+ }
+ }
+
+
+ // START OF PURE FORWARDING METHODS
+ @Override
+ public void consumeContent() throws IOException {
+ entity.consumeContent();
+ }
+
+
+ @Override
+ public Header getContentEncoding() {
+ return entity.getContentEncoding();
+ }
+
+ @Override
+ public long getContentLength() {
+ return entity.getContentLength();
+ }
+
+ @Override
+ public Header getContentType() {
+ return entity.getContentType();
+ }
+
+ @Override
+ public boolean isChunked() {
+ return entity.isChunked();
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return entity.isRepeatable();
+ }
+
+ @Override
+ public boolean isStreaming() {
+ return entity.isStreaming();
+ }
+
+ @Override
+ public void writeTo(OutputStream outstream) throws IOException {
+ entity.writeTo(outstream);
+ }
+ // END OF PURE FORWARDING METHODS
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/TimedStream.java b/container-search/src/main/java/com/yahoo/search/federation/http/TimedStream.java
new file mode 100644
index 00000000000..02777afb43c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/TimedStream.java
@@ -0,0 +1,111 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A stream which throws a TimeoutException if query timeout has been reached.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class TimedStream extends InputStream {
+
+ /**
+ * A time barrier value, the point in time from which on read operations will cause an exception.
+ */
+ private final long limit;
+
+ /**
+ * A wrapped InputStream instance.
+ */
+ private final InputStream content;
+
+ /**
+ * Wrap an InputStream to make read operations potentially fire off
+ * TimeoutException.
+ *
+ * <p>Typical use would be<br>
+ * <code>new TimedStream(httpEntity.getContent(), query.getStartTime(), query.getTimeout())</code>
+ *
+ * @param content
+ * the InputStream to wrap
+ * @param startTime
+ * start time of query
+ * @param timeout
+ * how long the query is allowed to run
+ */
+ public TimedStream(InputStream content, long startTime, long timeout) {
+ if (content == null) {
+ throw new IllegalArgumentException("Cannot instantiate TimedStream with null InputStream");
+ }
+ this.content = content;
+ // The reasion for doing it in here instead of outside the constructor
+ // is this makes the usage of the class more intuitive IMHO
+ this.limit = startTime + timeout;
+ }
+
+ private void checkTime(String message) {
+ if (System.currentTimeMillis() >= limit) {
+ throw new TimeoutException(message);
+ }
+ }
+
+ // START FORWARDING METHODS:
+ // All methods below are forwarding methods to the contained stream, where
+ // some do a timeout check.
+ @Override
+ public int read() throws IOException {
+ int data = content.read();
+ checkTime("Timed out during read().");
+ return data;
+ }
+
+ @Override
+ public int available() throws IOException {
+ return content.available();
+ }
+
+ @Override
+ public void close() throws IOException {
+ content.close();
+ }
+
+ @Override
+ public synchronized void mark(int readlimit) {
+ content.mark(readlimit);
+ }
+
+ @Override
+ public boolean markSupported() {
+ return content.markSupported();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int length = content.read(b, off, len);
+ checkTime("Timed out during read(byte[], int, int)");
+ return length;
+ }
+
+ @Override
+ public int read(byte[] b) throws IOException {
+ int length = content.read(b);
+ checkTime("Timed out during read(byte[])");
+ return length;
+ }
+
+ @Override
+ public synchronized void reset() throws IOException {
+ content.reset();
+ }
+
+ @Override
+ public long skip(long n) throws IOException {
+ long skipped = content.skip(n);
+ checkTime("Timed out during skip(long)");
+ return skipped;
+ }
+ // END FORWARDING METHODS
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/TimeoutException.java b/container-search/src/main/java/com/yahoo/search/federation/http/TimeoutException.java
new file mode 100644
index 00000000000..9e0536ea053
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/TimeoutException.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.http;
+
+/**
+ * Timeout marker for slow HTTP connections.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class TimeoutException extends RuntimeException {
+
+ /**
+ * Auto-generated version ID.
+ */
+ private static final long serialVersionUID = 7084147598258586559L;
+
+ public TimeoutException(String message) {
+ super(message);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/http/package-info.java b/container-search/src/main/java/com/yahoo/search/federation/http/package-info.java
new file mode 100644
index 00000000000..aa3d249ab66
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/http/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.federation.http;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/federation/package-info.java b/container-search/src/main/java/com/yahoo/search/federation/package-info.java
new file mode 100644
index 00000000000..008e339db4b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/package-info.java
@@ -0,0 +1,17 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * The federation layer on top of the search container. This contains
+ *
+ * <ul>
+ * <li>A model of Sources which can be selected in and for a Query and which are implemented
+ * by a Search Chain, and Providers which represents the connection to specific backends (these
+ * two are often 1-1 but not always)
+ * <li>The federation searcher responsible for forking a query to multiple sources in parallel
+ * <li>A simple searcher which can talk to other vespa services
+ * </ul>
+ */
+@ExportPackage
+package com.yahoo.search.federation;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/federation/selection/FederationTarget.java b/container-search/src/main/java/com/yahoo/search/federation/selection/FederationTarget.java
new file mode 100644
index 00000000000..676292d6a3a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/selection/FederationTarget.java
@@ -0,0 +1,68 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.selection;
+
+import java.util.Optional;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.model.federation.FederationOptions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * Represents a search chain that the federation searcher should send a query to,
+ * along with a timeout and
+ * custom data reserved for use by the TargetSelector.
+ *
+ * @author tonytv
+ */
+public final class FederationTarget<T> {
+ private final Chain<Searcher> chain;
+ private final FederationOptions federationOptions;
+ private final T customData;
+
+ public FederationTarget(Chain<Searcher> chain, FederationOptions federationOptions, T customData) {
+ checkNotNull(chain);
+ checkNotNull(federationOptions);
+
+ this.chain = chain;
+ this.federationOptions = federationOptions;
+ this.customData = customData;
+ }
+
+ public Chain<Searcher> getChain() {
+ return chain;
+ }
+
+ public FederationOptions getFederationOptions() {
+ return federationOptions;
+ }
+
+ /**
+ * Any data that the TargetSelector wants to associate with this target.
+ * Owned exclusively by the TargetSelector that created this instance.
+ */
+ public T getCustomData() {
+ return customData;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ FederationTarget that = (FederationTarget) o;
+
+ if (!chain.equals(that.chain)) return false;
+ if (customData != null ? !customData.equals(that.customData) : that.customData != null) return false;
+ if (!federationOptions.equals(that.federationOptions)) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = chain.hashCode();
+ result = 31 * result + federationOptions.hashCode();
+ return result;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/selection/TargetSelector.java b/container-search/src/main/java/com/yahoo/search/federation/selection/TargetSelector.java
new file mode 100644
index 00000000000..0f6bf2d5b71
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/selection/TargetSelector.java
@@ -0,0 +1,35 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.selection;
+
+import com.yahoo.processing.execution.chain.ChainRegistry;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.federation.selection.FederationTarget;
+
+import java.util.Collection;
+
+/**
+ * Allows adding extra targets that the federation searcher should federate to.
+ *
+ * For each federation search call, the federation searcher will call targetSelector.getTargets.
+ *
+ * Then, for each target, it will:
+ * 1) call modifyTargetQuery(target, query)
+ * 2) call modifyTargetResult(target, result)
+ *
+ * @author tonytv
+ */
+public interface TargetSelector<T> {
+ Collection<FederationTarget<T>> getTargets(Query query, ChainRegistry<Searcher> searcherChainRegistry);
+
+ /**
+ * For modifying the query before sending it to a the target
+ */
+ void modifyTargetQuery(FederationTarget<T> target, Query query);
+
+ /**
+ * For modifying the result produced by the target.
+ */
+ void modifyTargetResult(FederationTarget<T> target, Result result);
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/selection/package-info.java b/container-search/src/main/java/com/yahoo/search/federation/selection/package-info.java
new file mode 100644
index 00000000000..f3c289f6b43
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/selection/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.federation.selection;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainInvocationSpec.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainInvocationSpec.java
new file mode 100644
index 00000000000..7e82801d85f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainInvocationSpec.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.search.searchchain.model.federation.FederationOptions;
+
+import java.util.List;
+
+/**
+ * Specifices which search chain should be run and how it should be run.
+ *
+ * @author tonytv
+ */
+public class SearchChainInvocationSpec implements Cloneable {
+ public final ComponentId searchChainId;
+
+ public final ComponentId source;
+ public final ComponentId provider;
+
+ public final FederationOptions federationOptions;
+ public final List<String> documentTypes;
+
+ SearchChainInvocationSpec(ComponentId searchChainId,
+ ComponentId source, ComponentId provider, FederationOptions federationOptions,
+ List<String> documentTypes) {
+ this.searchChainId = searchChainId;
+ this.source = source;
+ this.provider = provider;
+ this.federationOptions = federationOptions;
+ this.documentTypes = documentTypes;
+ }
+
+ @Override
+ public SearchChainInvocationSpec clone() throws CloneNotSupportedException {
+ return (SearchChainInvocationSpec)super.clone();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainResolver.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainResolver.java
new file mode 100644
index 00000000000..fc70fb5e5e7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SearchChainResolver.java
@@ -0,0 +1,160 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.processing.request.Properties;
+import com.yahoo.search.searchchain.model.federation.FederationOptions;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+/**
+ * Resolves (source, provider) component specifications to a search chain invocation spec.
+ * The provider component specification is given by the entry in the queryMap with key
+ * 'source.&lt;source-name&gt;.provider'.
+ *
+ * <p>
+ * The diagram shows the relationship between source, provider and the result:
+ * (source is used to select row, provider is used to select column.)
+ * Provider id = null is used for regular search chains.
+ * </p>
+ *
+ * <pre>
+ * Provider id
+ * null
+ * |----+---+---+---|
+ * | o | | | |
+ * |----+---+---+---|
+ * Source id | | o | o | |
+ * |----+---+---+---|
+ * | | | | o |
+ * |----+---+---+---|
+ *
+ * o: SearchChainInvocationSpec
+ * </pre>
+ *
+ * @author tonytv
+ */
+public class SearchChainResolver {
+ private final ComponentRegistry<Target> targets;
+ private final SortedSet<Target> defaultTargets;
+
+ public static class Builder {
+
+ private SortedSet<Target> defaultTargets = new TreeSet<>();
+
+ private final ComponentRegistry<Target> targets = new ComponentRegistry<Target>() {
+ @Override
+ public void freeze() {
+ for (Target target : allComponents()) {
+ target.freeze();
+ }
+ super.freeze();
+ }
+ };
+
+ public Builder addSearchChain(ComponentId searchChainId) {
+ return addSearchChain(searchChainId, Collections.<String>emptyList());
+ }
+
+ public Builder addSearchChain(ComponentId searchChainId, FederationOptions federationOptions) {
+ return addSearchChain(searchChainId, federationOptions, Collections.<String>emptyList());
+ }
+
+ public Builder addSearchChain(ComponentId searchChainId, List<String> documentTypes) {
+ return addSearchChain(searchChainId, new FederationOptions(), documentTypes);
+ }
+
+ public Builder addSearchChain(ComponentId searchChainId, FederationOptions federationOptions,
+ List<String> documentTypes) {
+ registerTarget(new SingleTarget(searchChainId,
+ new SearchChainInvocationSpec(searchChainId, null, null, federationOptions, documentTypes), false));
+ return this;
+ }
+
+ private Builder registerTarget(SingleTarget singleTarget) {
+ targets.register(singleTarget.getId(), singleTarget);
+ if (singleTarget.useByDefault()) {
+ defaultTargets.add(singleTarget);
+ }
+ return this;
+ }
+
+ public Builder addSourceForProvider(ComponentId sourceId, ComponentId providerId, ComponentId searchChainId,
+ boolean isDefaultProviderForSource, FederationOptions federationOptions,
+ List<String> documentTypes) {
+
+ SearchChainInvocationSpec searchChainInvocationSpec =
+ new SearchChainInvocationSpec(searchChainId, sourceId, providerId, federationOptions, documentTypes);
+
+ SourcesTarget sourcesTarget = getOrRegisterSourceTarget(sourceId);
+ sourcesTarget.addSource(providerId, searchChainInvocationSpec, isDefaultProviderForSource);
+
+ registerTarget(new SingleTarget(searchChainId, searchChainInvocationSpec, true));
+ return this;
+ }
+
+ private SourcesTarget getOrRegisterSourceTarget(ComponentId sourceId) {
+ Target sourcesTarget = targets.getComponent(sourceId);
+ if (sourcesTarget == null) {
+ targets.register(sourceId, new SourcesTarget(sourceId));
+ return getOrRegisterSourceTarget(sourceId);
+ } else if (sourcesTarget instanceof SourcesTarget) {
+ return (SourcesTarget) sourcesTarget;
+ } else {
+ throw new IllegalStateException("Expected " + sourceId + " to be a source.");
+ }
+ }
+
+ public void useTargetByDefault(String targetId) {
+ Target target = targets.getComponent(targetId);
+ assert target != null : "Target not added yet.";
+
+ defaultTargets.add(target);
+ }
+
+ public SearchChainResolver build() {
+ targets.freeze();
+ return new SearchChainResolver(targets, defaultTargets);
+ }
+ }
+
+ private SearchChainResolver(ComponentRegistry<Target> targets, SortedSet<Target> defaultTargets) {
+ this.targets = targets;
+ this.defaultTargets = Collections.unmodifiableSortedSet(defaultTargets);
+ }
+
+
+ public SearchChainInvocationSpec resolve(ComponentSpecification sourceRef, Properties sourceToProviderMap)
+ throws UnresolvedSearchChainException {
+
+ Target target = resolveTarget(sourceRef);
+ return target.responsibleSearchChain(sourceToProviderMap);
+ }
+
+ private Target resolveTarget(ComponentSpecification sourceRef) throws UnresolvedSearchChainException {
+ Target target = targets.getComponent(sourceRef);
+ if (target == null) {
+ throw UnresolvedSourceRefException.createForMissingSourceRef(sourceRef);
+ }
+ return target;
+ }
+
+ public SortedSet<Target> allTopLevelTargets() {
+ SortedSet<Target> topLevelTargets = new TreeSet<>();
+ for (Target target : targets.allComponents()) {
+ if (!target.isDerived) {
+ topLevelTargets.add(target);
+ }
+ }
+ return topLevelTargets;
+ }
+
+ public SortedSet<Target> defaultTargets() {
+ return defaultTargets;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/SingleTarget.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SingleTarget.java
new file mode 100644
index 00000000000..4210b56a501
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SingleTarget.java
@@ -0,0 +1,36 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.processing.request.Properties;
+
+/**
+ * TODO: What is this?
+ *
+* @author tonytv
+*/
+public class SingleTarget extends Target {
+ private final SearchChainInvocationSpec searchChainInvocationSpec;
+
+ public SingleTarget(ComponentId id, SearchChainInvocationSpec searchChainInvocationSpec, boolean isDerived) {
+ super(id, isDerived);
+ this.searchChainInvocationSpec = searchChainInvocationSpec;
+ }
+
+ @Override
+ public SearchChainInvocationSpec responsibleSearchChain(Properties queryProperties) {
+ return searchChainInvocationSpec;
+ }
+
+ @Override
+ public String searchRefDescription() {
+ return localId.toString();
+ }
+
+ @Override
+ void freeze() {}
+
+ public final boolean useByDefault() {
+ return searchChainInvocationSpec.federationOptions.getUseByDefault();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/SourceRefResolver.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SourceRefResolver.java
new file mode 100644
index 00000000000..8de6635e517
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SourceRefResolver.java
@@ -0,0 +1,71 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import static com.yahoo.container.util.Util.quote;
+
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.processing.request.Properties;
+
+/**
+ * Maps a source reference to search chain invocation specs.
+ *
+ * @author tonytv
+ */
+public class SourceRefResolver {
+ private final SearchChainResolver searchChainResolver;
+
+ public SourceRefResolver(SearchChainResolver searchChainResolver) {
+ this.searchChainResolver = searchChainResolver;
+ }
+ public Set<SearchChainInvocationSpec> resolve(ComponentSpecification sourceRef, Properties sourceToProviderMap,
+ IndexFacts indexFacts)
+ throws UnresolvedSearchChainException {
+
+ try {
+ return new LinkedHashSet<>(Arrays.asList(searchChainResolver.resolve(sourceRef, sourceToProviderMap)));
+ } catch (UnresolvedSourceRefException e) {
+ return resolveClustersWithDocument(sourceRef, sourceToProviderMap, indexFacts);
+ }
+ }
+
+ private Set<SearchChainInvocationSpec> resolveClustersWithDocument(ComponentSpecification sourceRef,
+ Properties sourceToProviderMap,
+ IndexFacts indexFacts)
+ throws UnresolvedSearchChainException {
+
+ if (hasOnlyName(sourceRef)) {
+ Set<SearchChainInvocationSpec> clusterSearchChains = new LinkedHashSet<>();
+
+ List<String> clusters = indexFacts.clustersHavingSearchDefinition(sourceRef.getName());
+ for (String cluster : clusters) {
+ clusterSearchChains.add(resolveClusterSearchChain(cluster, sourceRef, sourceToProviderMap));
+ }
+
+ if (!clusterSearchChains.isEmpty())
+ return clusterSearchChains;
+ }
+
+ throw UnresolvedSourceRefException.createForMissingSourceRef(sourceRef);
+
+ }
+
+ private SearchChainInvocationSpec resolveClusterSearchChain(String cluster, ComponentSpecification sourceRef,
+ Properties sourceToProviderMap) throws UnresolvedSearchChainException {
+ try {
+ return searchChainResolver.resolve(new ComponentSpecification(cluster), sourceToProviderMap);
+ } catch (UnresolvedSearchChainException e) {
+ throw new UnresolvedSearchChainException("Failed to resolve cluster search chain " + quote(cluster) +
+ " when using source ref " + quote(sourceRef) + " as a document name.");
+ }
+ }
+
+ private boolean hasOnlyName(ComponentSpecification sourceSpec) {
+ return new ComponentSpecification(sourceSpec.getName()).equals(sourceSpec);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/SourcesTarget.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SourcesTarget.java
new file mode 100644
index 00000000000..bb1de051ed0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/SourcesTarget.java
@@ -0,0 +1,112 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+
+import com.google.common.base.Joiner;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.model.ComponentAdaptor;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.processing.request.Properties;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+
+public class SourcesTarget extends Target {
+ private ComponentRegistry<ComponentAdaptor<SearchChainInvocationSpec>> providerSources =
+ new ComponentRegistry<ComponentAdaptor<SearchChainInvocationSpec>>() {};
+ private SearchChainInvocationSpec defaultProviderSource;
+
+ public SourcesTarget(ComponentId sourceId) {
+ super(sourceId);
+ }
+
+ @Override
+ public SearchChainInvocationSpec responsibleSearchChain(Properties queryProperties) throws UnresolvedSearchChainException {
+ ComponentSpecification providerSpecification = providerSpecificationForSource(queryProperties);
+ if (providerSpecification == null) {
+ return defaultProviderSource;
+ } else {
+ return lookupProviderSource(providerSpecification);
+ }
+ }
+
+ @Override
+ public String searchRefDescription() {
+ StringBuilder builder = new StringBuilder(sourceId().stringValue());
+ builder.append("[provider = ").
+ append(Joiner.on(", ").join(allProviderIdsStringValue())).
+ append("]");
+ return builder.toString();
+ }
+
+ private SortedSet<String> allProviderIdsStringValue() {
+ SortedSet<String> result = new TreeSet<>();
+ for (ComponentAdaptor<SearchChainInvocationSpec> providerSource : providerSources.allComponents()) {
+ result.add(providerSource.getId().stringValue());
+ }
+ return result;
+ }
+
+ private SearchChainInvocationSpec lookupProviderSource(ComponentSpecification providerSpecification)
+ throws UnresolvedSearchChainException {
+ ComponentAdaptor<SearchChainInvocationSpec> providerSource = providerSources.getComponent(providerSpecification);
+
+ if (providerSource == null)
+ throw UnresolvedProviderException.createForMissingProvider(sourceId(), providerSpecification);
+
+ return providerSource.model;
+ }
+
+ public void freeze() {
+ if (defaultProviderSource == null)
+ throw new RuntimeException("Null default provider source for source " + sourceId() + ".");
+
+ providerSources.freeze();
+ }
+
+ public void addSource(ComponentId providerId, SearchChainInvocationSpec searchChainInvocationSpec,
+ boolean isDefaultProviderForSource) {
+ providerSources.register(providerId, new ComponentAdaptor<>(providerId, searchChainInvocationSpec));
+
+ if (isDefaultProviderForSource) {
+ setDefaultProviderSource(searchChainInvocationSpec);
+ }
+ }
+
+ private void setDefaultProviderSource(SearchChainInvocationSpec searchChainInvocationSpec) {
+ if (defaultProviderSource != null)
+ throw new RuntimeException("Tried to set two default providers for source " + sourceId() + ".");
+
+ defaultProviderSource = searchChainInvocationSpec;
+ }
+
+ ComponentId sourceId() {
+ return localId;
+ }
+
+
+ /**
+ * Looks up source.(sourceId).provider in the query properties.
+ * @return null if the default provider should be used
+ */
+ private ComponentSpecification providerSpecificationForSource(Properties queryProperties) {
+ String spec = queryProperties.getString("source." + sourceId().stringValue() + ".provider");
+ return ComponentSpecification.fromString(spec);
+ }
+
+ public SearchChainInvocationSpec defaultProviderSource() {
+ return defaultProviderSource;
+ }
+
+ public List<SearchChainInvocationSpec> allProviderSources() {
+ List<SearchChainInvocationSpec> allProviderSources = new ArrayList<>();
+ for (ComponentAdaptor<SearchChainInvocationSpec> component : providerSources.allComponents()) {
+ allProviderSources.add(component.model);
+ }
+ return allProviderSources;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/Target.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/Target.java
new file mode 100644
index 00000000000..4cf5d406959
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/Target.java
@@ -0,0 +1,31 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.component.ComponentId;
+import com.yahoo.processing.request.Properties;
+
+/**
+ * TODO: What's this?
+ *
+* @author tonytv
+*/
+public abstract class Target extends AbstractComponent {
+ final ComponentId localId;
+ final boolean isDerived;
+
+ Target(ComponentId localId, boolean derived) {
+ super(localId);
+ this.localId = localId;
+ isDerived = derived;
+ }
+
+ Target(ComponentId localId) {
+ this(localId, false);
+ }
+
+ public abstract SearchChainInvocationSpec responsibleSearchChain(Properties queryProperties) throws UnresolvedSearchChainException;
+ public abstract String searchRefDescription();
+
+ abstract void freeze();
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedProviderException.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedProviderException.java
new file mode 100644
index 00000000000..50b2dc95660
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedProviderException.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+
+import static com.yahoo.container.util.Util.quote;
+
+/**
+ * @author tonytv
+ */
+@SuppressWarnings("serial")
+class UnresolvedProviderException extends UnresolvedSearchChainException {
+ UnresolvedProviderException(String msg) {
+ super(msg);
+ }
+
+ static UnresolvedSearchChainException createForMissingProvider(ComponentId source,
+ ComponentSpecification provider) {
+ return new UnresolvedProviderException("No provider " + quote(provider) + " for source " + quote(source) + ".");
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSearchChainException.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSearchChainException.java
new file mode 100644
index 00000000000..b8417a3d05a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSearchChainException.java
@@ -0,0 +1,13 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+/**
+ * Thrown if a search chain can not be resolved from one or more ids.
+ * @author tonytv
+ */
+@SuppressWarnings("serial")
+public class UnresolvedSearchChainException extends Exception {
+ public UnresolvedSearchChainException(String msg) {
+ super(msg);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSourceRefException.java b/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSourceRefException.java
new file mode 100644
index 00000000000..4c15366914b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/sourceref/UnresolvedSourceRefException.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.sourceref;
+
+import com.yahoo.component.ComponentSpecification;
+
+import static com.yahoo.container.util.Util.quote;
+
+/**
+ * @author tonytv
+ */
+@SuppressWarnings("serial")
+class UnresolvedSourceRefException extends UnresolvedSearchChainException {
+ UnresolvedSourceRefException(String msg) {
+ super(msg);
+ }
+
+
+ static UnresolvedSearchChainException createForMissingSourceRef(ComponentSpecification source) {
+ return new UnresolvedSourceRefException("Could not resolve source ref " + quote(source) + ".");
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/vespa/QueryMarshaller.java b/container-search/src/main/java/com/yahoo/search/federation/vespa/QueryMarshaller.java
new file mode 100644
index 00000000000..554424c267f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/vespa/QueryMarshaller.java
@@ -0,0 +1,170 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.vespa;
+
+import java.util.Iterator;
+
+import com.yahoo.prelude.query.*;
+
+/**
+ * Marshal a query stack into an advanced query string suitable for
+ * passing to another QRS.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ * @author <a href="mailto:rafan@yahoo-inc.com">Rong-En Fan</a>
+ */
+public class QueryMarshaller {
+ private boolean atRoot = true;
+
+ public String marshal(Item root) {
+ if (root == null || root instanceof NullItem) {
+ return null;
+ }
+ StringBuilder s = new StringBuilder();
+ marshal(root, s);
+ atRoot = true;
+ return s.toString();
+ }
+
+ /**
+ * We do not yet care about exact match indices
+ */
+ private void marshal(Item root, StringBuilder s) {
+ switch (root.getItemType()) {
+ case OR:
+ marshalOr((OrItem) root, s);
+ break;
+ case AND:
+ marshalAnd((CompositeItem) root, s);
+ break;
+ case NOT:
+ marshalNot((NotItem) root, s);
+ break;
+ case RANK:
+ marshalRank((RankItem) root, s);
+ break;
+ case WORD:
+ case INT:
+ case PREFIX:
+ case SUBSTRING:
+ case SUFFIX:
+ marshalWord((TermItem) root, s);
+ break;
+ case PHRASE:
+ // PhraseItem and PhraseSegmentItem don't add quotes for segmented
+ // termse
+ if (root instanceof PhraseSegmentItem) {
+ marshalPhrase((PhraseSegmentItem) root, s);
+ } else {
+ marshalPhrase((PhraseItem) root, s);
+ }
+ break;
+ case NEAR:
+ marshalNear((NearItem) root, s);
+ break;
+ case ONEAR:
+ marshalNear((ONearItem) root, s);
+ break;
+ case WEAK_AND:
+ marshalWeakAnd((WeakAndItem)root, s);
+ default:
+ break;
+ }
+ }
+
+
+ private void marshalWord(TermItem item, StringBuilder s) {
+ String index = item.getIndexName();
+ if (index.length() != 0) {
+ s.append(item.getIndexName()).append(':');
+ }
+ s.append(item.stringValue());
+ if (item.getWeight() != Item.DEFAULT_WEIGHT)
+ s.append("!").append(item.getWeight());
+ }
+
+ private void marshalRank(RankItem root, StringBuilder s) {
+ marshalComposite("RANK", root, s);
+ }
+
+ private void marshalNot(NotItem root, StringBuilder s) {
+ marshalComposite("ANDNOT", root, s);
+ }
+
+ private void marshalOr(OrItem root, StringBuilder s) {
+ marshalComposite("OR", root, s);
+ }
+
+ /**
+ * Dump WORD items, and add space between each of them unless those
+ * words came from segmentation.
+ *
+ * @param root CompositeItem
+ * @param s current marshaled query
+ */
+ private void dumpWords(CompositeItem root, StringBuilder s) {
+ for (Iterator<Item> i = root.getItemIterator(); i.hasNext();) {
+ Item word = i.next();
+ boolean useSeparator = true;
+ if (word instanceof TermItem) {
+ s.append(((TermItem) word).stringValue());
+ if (word instanceof WordItem) {
+ useSeparator = !((WordItem) word).isFromSegmented();
+ }
+ } else {
+ dumpWords((CompositeItem) word, s);
+ }
+ if (useSeparator && i.hasNext()) {
+ s.append(' ');
+ }
+ }
+ }
+
+ private void marshalPhrase(PhraseItem root, StringBuilder s) {
+ marshalPhrase(root, s, root.isExplicit(), false);
+ }
+
+ private void marshalPhrase(PhraseSegmentItem root, StringBuilder s) {
+ marshalPhrase(root, s, root.isExplicit(), true);
+ }
+
+ private void marshalPhrase(IndexedItem root, StringBuilder s, boolean isExplicit, boolean isSegmented) {
+ String index = root.getIndexName();
+ if (index.length() != 0) {
+ s.append(root.getIndexName()).append(':');
+ }
+ if (isExplicit || !isSegmented) s.append('"');
+ dumpWords((CompositeItem) root, s);
+ if (isExplicit || !isSegmented) s.append('"');
+ }
+
+ private void marshalNear(NearItem root, StringBuilder s) {
+ marshalComposite(root.getName() + "(" + root.getDistance() + ")", root, s);
+ }
+
+ // Not only AndItem returns ItemType.AND
+ private void marshalAnd(CompositeItem root, StringBuilder s) {
+ marshalComposite("AND", root, s);
+ }
+
+ private void marshalWeakAnd(WeakAndItem root, StringBuilder s) {
+ marshalComposite("WAND(" + root.getN() + ")", root, s);
+ }
+
+ private void marshalComposite(String operator, CompositeItem root, StringBuilder s) {
+ boolean useParen = !atRoot;
+ if (useParen) {
+ s.append("( ");
+ } else {
+ atRoot = false;
+ }
+ for (Iterator<Item> i = root.getItemIterator(); i.hasNext();) {
+ Item item = i.next();
+ marshal(item, s);
+ if (i.hasNext())
+ s.append(' ').append(operator).append(' ');
+ }
+ if (useParen) {
+ s.append(" )");
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/vespa/ResultBuilder.java b/container-search/src/main/java/com/yahoo/search/federation/vespa/ResultBuilder.java
new file mode 100644
index 00000000000..1361c7c14db
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/vespa/ResultBuilder.java
@@ -0,0 +1,642 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.vespa;
+
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.hitfield.XMLString;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.result.HitGroup;
+import com.yahoo.search.result.Relevance;
+import com.yahoo.text.XML;
+import com.yahoo.text.DoubleParser;
+import org.xml.sax.*;
+import org.xml.sax.helpers.DefaultHandler;
+import org.xml.sax.helpers.XMLReaderFactory;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+
+import static com.yahoo.text.Lowercase.toLowerCase;
+
+/**
+ * Parse Vespa XML results and create Result instances.
+ *
+ * <p> TODO: Ripe for a rewrite or major refactoring.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@SuppressWarnings("deprecation")
+public class ResultBuilder extends DefaultHandler {
+ private static final String ERROR = "error";
+
+ private static final String FIELD = "field";
+
+ private static Logger log = Logger.getLogger(ResultBuilder.class.getName());
+
+ /** Namespaces feature id (http://xml.org/sax/features/namespaces). */
+ protected static final String NAMESPACES_FEATURE_ID = "http://xml.org/sax/features/namespaces";
+
+ /**
+ * Namespace prefixes feature id
+ * (http://xml.org/sax/features/namespace-prefixes).
+ */
+ protected static final String NAMESPACE_PREFIXES_FEATURE_ID = "http://xml.org/sax/features/namespace-prefixes";
+
+ /** Validation feature id (http://xml.org/sax/features/validation). */
+ protected static final String VALIDATION_FEATURE_ID = "http://xml.org/sax/features/validation";
+
+ /**
+ * Schema validation feature id
+ * (http://apache.org/xml/features/validation/schema).
+ */
+ protected static final String SCHEMA_VALIDATION_FEATURE_ID = "http://apache.org/xml/features/validation/schema";
+
+ /**
+ * Dynamic validation feature id
+ * (http://apache.org/xml/features/validation/dynamic).
+ */
+ protected static final String DYNAMIC_VALIDATION_FEATURE_ID = "http://apache.org/xml/features/validation/dynamic";
+
+ // default settings
+
+ /** Default parser name. */
+ protected static final String DEFAULT_PARSER_NAME = "org.apache.xerces.parsers.SAXParser";
+
+ /** Default namespaces support (false). */
+ protected static final boolean DEFAULT_NAMESPACES = false;
+
+ /** Default namespace prefixes (false). */
+ protected static final boolean DEFAULT_NAMESPACE_PREFIXES = false;
+
+ /** Default validation support (false). */
+ protected static final boolean DEFAULT_VALIDATION = false;
+
+ /** Default Schema validation support (false). */
+ protected static final boolean DEFAULT_SCHEMA_VALIDATION = false;
+
+ /** Default dynamic validation support (false). */
+ protected static final boolean DEFAULT_DYNAMIC_VALIDATION = false;
+
+ private StringBuilder fieldContent;
+
+ private String fieldName;
+
+ private int fieldLevel = 0;
+
+ private boolean hasLiteralTags = false;
+
+ private Map<String, Object> hitFields = new HashMap<>();
+ private String hitType;
+ private String hitRelevance;
+ private String hitSource;
+
+ private int offset = 0;
+
+ private List<Tag> tagStack = new ArrayList<>();
+
+ private final XMLReader parser;
+
+ private Query query;
+
+ private Result result;
+
+ private static enum ResultPart {
+ ROOT, ERRORDETAILS, HIT, HITGROUP;
+ }
+
+ Deque<ResultPart> location = new ArrayDeque<>(10);
+
+ private String currentErrorCode;
+
+ private String currentError;
+
+ private Deque<HitGroup> hitGroups = new ArrayDeque<>(5);
+
+ private static class Tag {
+ public final String name;
+
+ /**
+ * Offset is a number which is generated for all data and tags inside
+ * fields, used to determine whether a tag was closed without enclosing
+ * any characters or other tags.
+ */
+ public final int offset;
+
+ public Tag(final String name, final int offset) {
+ this.name = name;
+ this.offset = offset;
+ }
+
+ @Override
+ public String toString() {
+ return name + '(' + Integer.valueOf(offset) + ')';
+ }
+ }
+
+ /** Default constructor. */
+ public ResultBuilder() throws RuntimeException {
+ this(createParser());
+ }
+
+ public ResultBuilder(XMLReader parser) {
+ this.parser = parser;
+ this.parser.setContentHandler(this);
+ this.parser.setErrorHandler(this);
+ }
+
+ public static XMLReader createParser() {
+ ClassLoader savedContextClassLoader = Thread.currentThread().getContextClassLoader();
+ Thread.currentThread().setContextClassLoader(org.apache.xerces.parsers.SAXParser.class.getClassLoader());
+
+ try {
+ XMLReader reader = XMLReaderFactory.createXMLReader(DEFAULT_PARSER_NAME);
+ setParserFeatures(reader);
+ return reader;
+ } catch (Exception e) {
+ throw new RuntimeException("error: Unable to instantiate parser ("
+ + DEFAULT_PARSER_NAME + ")", e);
+ } finally {
+ Thread.currentThread().setContextClassLoader(savedContextClassLoader);
+ }
+ }
+
+ private static void setParserFeatures(XMLReader reader) {
+ try {
+ reader.setFeature(NAMESPACES_FEATURE_ID, DEFAULT_NAMESPACES);
+ } catch (SAXException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not support feature ("
+ + NAMESPACES_FEATURE_ID + ")");
+ }
+ try {
+ reader.setFeature(NAMESPACE_PREFIXES_FEATURE_ID,
+ DEFAULT_NAMESPACE_PREFIXES);
+ } catch (SAXException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not support feature ("
+ + NAMESPACE_PREFIXES_FEATURE_ID + ")");
+ }
+ try {
+ reader.setFeature(VALIDATION_FEATURE_ID, DEFAULT_VALIDATION);
+ } catch (SAXException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not support feature ("
+ + VALIDATION_FEATURE_ID + ")");
+ }
+ try {
+ reader.setFeature(SCHEMA_VALIDATION_FEATURE_ID,
+ DEFAULT_SCHEMA_VALIDATION);
+ } catch (SAXNotRecognizedException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not recognize feature ("
+ + SCHEMA_VALIDATION_FEATURE_ID + ")");
+
+ } catch (SAXNotSupportedException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not support feature ("
+ + SCHEMA_VALIDATION_FEATURE_ID + ")");
+ }
+
+ try {
+ reader.setFeature(DYNAMIC_VALIDATION_FEATURE_ID,
+ DEFAULT_DYNAMIC_VALIDATION);
+ } catch (SAXNotRecognizedException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not recognize feature ("
+ + DYNAMIC_VALIDATION_FEATURE_ID + ")");
+
+ } catch (SAXNotSupportedException e) {
+ log.log(LogLevel.WARNING, "warning: Parser does not support feature ("
+ + DYNAMIC_VALIDATION_FEATURE_ID + ")");
+ }
+ }
+
+ @Override
+ public void startDocument() throws SAXException {
+ reset();
+ result = new Result(query);
+ hitGroups.addFirst(result.hits());
+ location.addFirst(ResultPart.ROOT);
+ return;
+ }
+
+ private void reset() {
+ result = null;
+ fieldLevel = 0;
+ hasLiteralTags = false;
+ tagStack = null;
+ fieldContent = null;
+ offset = 0;
+ currentError = null;
+ currentErrorCode = null;
+ hitGroups.clear();
+ location.clear();
+ }
+
+ @Override
+ public void startElement(String uri, String local, String raw,
+ Attributes attrs) throws SAXException {
+ // "Everybody" wants this switch to be moved into the
+ // enum class instead, but in this case, I find the classic
+ // approach more readable.
+ switch (location.peekFirst()) {
+ case HIT:
+ if (fieldLevel > 0) {
+ tagInField(raw, attrs, FIELD);
+ ++offset;
+ return;
+ }
+ if (FIELD.equals(raw)) {
+ ++fieldLevel;
+ fieldName = attrs.getValue("name");
+ fieldContent = new StringBuilder();
+ hasLiteralTags = false;
+ }
+ break;
+ case ERRORDETAILS:
+ if (fieldLevel > 0) {
+ tagInField(raw, attrs, ERROR);
+ ++offset;
+ return;
+ }
+ if (ERROR.equals(raw)) {
+ if (attrs != null) {
+ currentErrorCode = attrs.getValue("code");
+ currentError = attrs.getValue("error");
+ }
+ ++fieldLevel;
+ fieldContent = new StringBuilder();
+ hasLiteralTags = false;
+ }
+ break;
+ case HITGROUP:
+ if ("hit".equals(raw)) {
+ startHit(attrs);
+ } else if ("group".equals(raw)) {
+ startHitGroup(attrs);
+ }
+ break;
+ case ROOT:
+ if ("hit".equals(raw)) {
+ startHit(attrs);
+ } else if ("errordetails".equals(raw)) {
+ location.addFirst(ResultPart.ERRORDETAILS);
+ } else if ("result".equals(raw)) {
+ if (attrs != null) {
+ String total = attrs.getValue("total-hit-count");
+ if (total != null) {
+ result.setTotalHitCount(Long.valueOf(total));
+ }
+ }
+ } else if ("group".equals(raw)) {
+ startHitGroup(attrs);
+ } else if (ERROR.equals(raw)) {
+ if (attrs != null) {
+ currentErrorCode = attrs.getValue("code");
+ fieldContent = new StringBuilder();
+ }
+ }
+ break;
+ }
+ ++offset;
+ }
+
+ private void startHitGroup(Attributes attrs) {
+ HitGroup g = new HitGroup();
+ Set<String> types = g.types();
+
+ final String source;
+ if (attrs != null) {
+ String groupType = attrs.getValue("type");
+ if (groupType != null) {
+ for (String s : groupType.split(" ")) {
+ if (s.length() > 0) {
+ types.add(s);
+ }
+ }
+ }
+
+ source = attrs.getValue("source");
+ } else {
+ source = null;
+ }
+
+ g.setId((source != null) ? source : "dummy");
+
+ hitGroups.peekFirst().add(g);
+ hitGroups.addFirst(g);
+ location.addFirst(ResultPart.HITGROUP);
+ }
+
+ private void startHit(Attributes attrs) {
+ hitFields.clear();
+ location.addFirst(ResultPart.HIT);
+ if (attrs != null) {
+ hitRelevance = attrs.getValue("relevancy");
+ hitSource = attrs.getValue("source");
+ hitType = attrs.getValue("type");
+ } else {
+ hitRelevance = null;
+ hitSource = null;
+ hitType = null;
+ }
+ }
+
+ private void tagInField(String tag, Attributes attrs, String enclosingTag) {
+ if (!hasLiteralTags) {
+ hasLiteralTags = true;
+ String fieldTillNow = XML.xmlEscape(fieldContent.toString(), false);
+ fieldContent = new StringBuilder(fieldTillNow);
+ tagStack = new ArrayList<>();
+ }
+ if (enclosingTag.equals(tag)) {
+ ++fieldLevel;
+ }
+ if (tagStack.size() > 0) {
+ Tag prevTag = tagStack.get(tagStack.size() - 1);
+ if (prevTag != null && (prevTag.offset + 1) == offset) {
+ fieldContent.append(">");
+ }
+ }
+ fieldContent.append("<").append(tag);
+ if (attrs != null) {
+ int attrCount = attrs.getLength();
+ for (int i = 0; i < attrCount; i++) {
+ fieldContent.append(" ").append(attrs.getQName(i))
+ .append("=\"").append(
+ XML.xmlEscape(attrs.getValue(i), true)).append(
+ "\"");
+ }
+ }
+ tagStack.add(new Tag(tag, offset));
+ }
+
+ private void endElementInField(String qName, String enclosingTag) {
+ Tag prevTag = tagStack.get(tagStack.size() - 1);
+ if (qName.equals(prevTag.name) && offset == (prevTag.offset + 1)) {
+ fieldContent.append(" />");
+ } else {
+ fieldContent.append("</").append(qName).append('>');
+ }
+ if (prevTag.name.equals(qName)) {
+ tagStack.remove(tagStack.size() - 1);
+ }
+ }
+
+ private void endElementInHitField(String qName) {
+ if (FIELD.equals(qName) && --fieldLevel == 0) {
+ Object content;
+ if (hasLiteralTags) {
+ content = new XMLString(fieldContent.toString());
+ } else {
+ content = fieldContent.toString();
+ }
+ hitFields.put(fieldName, content);
+ if ("collapseId".equals(fieldName)) {
+ hitFields.put(fieldName, Integer.valueOf(content.toString()));
+ }
+ fieldName = null;
+ fieldContent = null;
+ tagStack = null;
+ } else {
+ Tag prevTag = tagStack.get(tagStack.size() - 1);
+ if (qName.equals(prevTag.name) && offset == (prevTag.offset + 1)) {
+ fieldContent.append(" />");
+ } else {
+ fieldContent.append("</").append(qName).append('>');
+ }
+ if (prevTag.name.equals(qName)) {
+ tagStack.remove(tagStack.size() - 1);
+ }
+ }
+ }
+ @Override
+ public void characters(char ch[], int start, int length)
+ throws SAXException {
+
+ switch (location.peekFirst()) {
+ case ERRORDETAILS:
+ case HIT:
+ if (fieldLevel > 0) {
+ if (hasLiteralTags) {
+ if (tagStack.size() > 0) {
+ Tag tag = tagStack.get(tagStack.size() - 1);
+ if (tag != null && (tag.offset + 1) == offset) {
+ fieldContent.append(">");
+ }
+ }
+ fieldContent.append(
+ XML.xmlEscape(new String(ch, start, length), false));
+ } else {
+ fieldContent.append(ch, start, length);
+ }
+ }
+ break;
+ default:
+ if (fieldContent != null) {
+ fieldContent.append(ch, start, length);
+ }
+ break;
+ }
+ ++offset;
+ }
+
+ @Override
+ public void ignorableWhitespace(char ch[], int start, int length)
+ throws SAXException {
+ return;
+ }
+
+ @Override
+ public void processingInstruction(String target, String data)
+ throws SAXException {
+ return;
+ }
+
+ @Override
+ public void endElement(String namespaceURI, String localName, String qName)
+ throws SAXException {
+ switch (location.peekFirst()) {
+ case HITGROUP:
+ if ("group".equals(qName)) {
+ hitGroups.removeFirst();
+ location.removeFirst();
+ }
+ break;
+ case HIT:
+ if (fieldLevel > 0) {
+ endElementInHitField(qName);
+ } else if ("hit".equals(qName)) {
+ //assert(hitKeys.size() == hitValues.size());
+ //We try to get either uri or documentID and use that as id
+ Object docId = extractDocumentID();
+ Hit newHit = new Hit(docId.toString());
+ if (hitRelevance != null) newHit.setRelevance(new Relevance(DoubleParser.parse(hitRelevance)));
+ if(hitSource != null) newHit.setSource(hitSource);
+ if(hitType != null) {
+ for(String type: hitType.split(" ")) {
+ newHit.types().add(type);
+ }
+ }
+ for(Map.Entry<String, Object> field : hitFields.entrySet()) {
+ newHit.setField(field.getKey(), field.getValue());
+ }
+
+ hitGroups.peekFirst().add(newHit);
+ location.removeFirst();
+ }
+ break;
+ case ERRORDETAILS:
+ if (fieldLevel == 1 && ERROR.equals(qName)) {
+ ErrorMessage error = new ErrorMessage(Integer.valueOf(currentErrorCode),
+ currentError,
+ fieldContent.toString());
+ hitGroups.peekFirst().addError(error);
+ currentError = null;
+ currentErrorCode = null;
+ fieldContent = null;
+ tagStack = null;
+ fieldLevel = 0;
+ } else if (fieldLevel > 0) {
+ endElementInField(qName, ERROR);
+ } else if ("errordetails".equals(qName)) {
+ location.removeFirst();
+ }
+ break;
+ case ROOT:
+ if (ERROR.equals(qName)) {
+ ErrorMessage error = new ErrorMessage(Integer.valueOf(currentErrorCode),
+ fieldContent.toString());
+ hitGroups.peekFirst().setError(error);
+ currentErrorCode = null;
+ fieldContent = null;
+ }
+ break;
+ default:
+ break;
+ }
+ ++offset;
+ }
+
+ private Object extractDocumentID() {
+ Object docId = null;
+ if (hitFields.containsKey("uri")) {
+ docId = hitFields.get("uri");
+ } else {
+ final String documentId = "documentId";
+ if (hitFields.containsKey(documentId)) {
+ docId = hitFields.get(documentId);
+ } else {
+ final String lcDocumentId = toLowerCase(documentId);
+ for (Map.Entry<String, Object> e : hitFields.entrySet()) {
+ String key = e.getKey();
+ // case insensitive matching, checking length first hoping to avoid some lowercasing
+ if (documentId.length() == key.length() && lcDocumentId.equals(toLowerCase(key))) {
+ docId = e.getValue();
+ break;
+ }
+ }
+ }
+ }
+ if (docId == null) {
+ docId = "dummy";
+ log.info("Results from vespa backend did not contain either uri or documentId");
+ }
+ return docId;
+ }
+
+ @Override
+ public void warning(SAXParseException ex) throws SAXException {
+ printError("Warning", ex);
+ }
+
+ @Override
+ public void error(SAXParseException ex) throws SAXException {
+ printError("Error", ex);
+ }
+
+ @Override
+ public void fatalError(SAXParseException ex) throws SAXException {
+ printError("Fatal Error", ex);
+ // throw ex;
+ }
+
+ /** Prints the error message. */
+ protected void printError(String type, SAXParseException ex) {
+ StringBuilder errorMessage = new StringBuilder();
+
+ errorMessage.append(type);
+ if (ex != null) {
+ String systemId = ex.getSystemId();
+ if (systemId != null) {
+ int index = systemId.lastIndexOf('/');
+ if (index != -1)
+ systemId = systemId.substring(index + 1);
+ errorMessage.append(' ').append(systemId);
+ }
+ }
+ errorMessage.append(':')
+ .append(ex.getLineNumber())
+ .append(':')
+ .append(ex.getColumnNumber())
+ .append(": ")
+ .append(ex.getMessage());
+ log.log(LogLevel.WARNING, errorMessage.toString());
+
+ }
+
+ public Result parse(String identifier, Query query) {
+ Result toReturn;
+
+ setQuery(query);
+ try {
+ parser.parse(identifier);
+ } catch (SAXParseException e) {
+ // ignore
+ } catch (Exception e) {
+ log.log(LogLevel.WARNING, "Error parsing result from Vespa",e);
+ Exception se = e;
+ if (e instanceof SAXException) {
+ se = ((SAXException) e).getException();
+ }
+ if (se != null)
+ se.printStackTrace(System.err);
+ else
+ e.printStackTrace(System.err);
+ }
+ toReturn = result;
+ reset();
+ return toReturn;
+ }
+
+ public Result parse(InputSource input, Query query) {
+ Result toReturn;
+
+ setQuery(query);
+ try {
+ parser.parse(input);
+ } catch (SAXParseException e) {
+ // ignore
+ } catch (Exception e) {
+ log.log(LogLevel.WARNING, "Error parsing result from Vespa",e);
+ Exception se = e;
+ if (e instanceof SAXException) {
+ se = ((SAXException) e).getException();
+ }
+ if (se != null)
+ se.printStackTrace(System.err);
+ else
+ e.printStackTrace(System.err);
+ }
+ toReturn = result;
+ reset();
+ return toReturn;
+ }
+
+
+ private void setQuery(Query query) {
+ this.query = query;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/vespa/VespaSearcher.java b/container-search/src/main/java/com/yahoo/search/federation/vespa/VespaSearcher.java
new file mode 100644
index 00000000000..26c9b8ad2cd
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/vespa/VespaSearcher.java
@@ -0,0 +1,270 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.federation.vespa;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Map;
+import java.util.Set;
+
+import org.xml.sax.InputSource;
+import org.xml.sax.XMLReader;
+
+import com.google.inject.Inject;
+import com.yahoo.collections.Tuple2;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.Version;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.language.Linguistics;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.QueryCanonicalizer;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.cache.QrBinaryCacheConfig;
+import com.yahoo.search.cache.QrBinaryCacheRegionConfig;
+import com.yahoo.search.federation.FederationSearcher;
+import com.yahoo.search.federation.ProviderConfig;
+import com.yahoo.search.federation.http.ConfiguredHTTPProviderSearcher;
+import com.yahoo.search.federation.http.Connection;
+import com.yahoo.search.intent.model.IntentModel;
+import com.yahoo.search.query.QueryTree;
+import com.yahoo.search.query.textserialize.TextSerialize;
+import com.yahoo.search.yql.MinimalQueryInserter;
+import com.yahoo.statistics.Statistics;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+/**
+ * Backend searcher for external Vespa clusters (queried over http).
+ *
+ * <p>If the "sources" argument should be honored on an external cluster
+ * when using YQL+, override {@link #chooseYqlSources(Set)}.</p>
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@Provides("Vespa")
+@After("*")
+public class VespaSearcher extends ConfiguredHTTPProviderSearcher {
+ private final ThreadLocal<XMLReader> readerHolder = new ThreadLocal<>();
+ private final Query.Type queryType;
+ private final Tuple2<String, Version> segmenterVersion;
+
+ private static final CompoundName select = new CompoundName("select");
+ private static final CompoundName streamingUserid = new CompoundName(
+ "streaming.userid");
+ private static final CompoundName streamingGroupname = new CompoundName(
+ "streaming.groupname");
+ private static final CompoundName streamingSelection = new CompoundName(
+ "streaming.selection");
+
+ /** Create an instance from configuration */
+ public VespaSearcher(ComponentId id, ProviderConfig config,
+ QrBinaryCacheConfig c, QrBinaryCacheRegionConfig r,
+ Statistics statistics) {
+ this(id, config, c, r, statistics, null);
+ }
+
+ /**
+ * Create an instance from configuration
+ *
+ * @param linguistics used for generating meta info for YQL+
+ */
+ @Inject
+ public VespaSearcher(ComponentId id, ProviderConfig config,
+ QrBinaryCacheConfig c, QrBinaryCacheRegionConfig r,
+ Statistics statistics, @Nullable Linguistics linguistics) {
+ super(id, config, c, r, statistics);
+ queryType = toQueryType(config.queryType());
+ if (linguistics == null) {
+ segmenterVersion = null;
+ } else {
+ segmenterVersion = linguistics.getVersion(Linguistics.Component.SEGMENTER);
+ }
+ }
+
+ /**
+ * Create an instance from direct parameters having a single connection.
+ * Useful for testing
+ */
+ public VespaSearcher(String idString, String host, int port, String path) {
+ super(idString, host, port, path, Statistics.nullImplementation);
+ queryType = toQueryType(ProviderConfig.QueryType.LEGACY);
+ segmenterVersion = null;
+ }
+
+ void addProperty(Map<String, String> queryMap, Query query,
+ CompoundName property) {
+ Object o = query.properties().get(property);
+ if (o != null) {
+ queryMap.put(property.toString(), o.toString());
+ }
+ }
+
+ @Override
+ public Map<String, String> getQueryMap(Query query) {
+ Map<String, String> queryMap = getQueryMapWithoutHitsOffset(query);
+ queryMap.put("offset", Integer.toString(query.getOffset()));
+ queryMap.put("hits", Integer.toString(query.getHits()));
+ queryMap.put("presentation.format", "xml");
+
+ addProperty(queryMap, query, select);
+ addProperty(queryMap, query, streamingUserid);
+ addProperty(queryMap, query, streamingGroupname);
+ addProperty(queryMap, query, streamingSelection);
+ return queryMap;
+ }
+
+ @Override
+ public Map<String, String> getCacheKey(Query q) {
+ return getQueryMapWithoutHitsOffset(q);
+ }
+
+ private Map<String, String> getQueryMapWithoutHitsOffset(Query query) {
+ Map<String, String> queryMap = super.getQueryMap(query);
+ if (queryType == Query.Type.YQL) {
+ queryMap.put(MinimalQueryInserter.YQL.toString(), marshalQuery(query));
+ } else {
+ queryMap.put("query", marshalQuery(query.getModel().getQueryTree()));
+ queryMap.put("type", queryType.toString());
+ }
+
+ addNonExcludedSourceProperties(query, queryMap);
+ return queryMap;
+ }
+
+ Query.Type toQueryType(ProviderConfig.QueryType.Enum providerQueryType) {
+ if (providerQueryType == ProviderConfig.QueryType.LEGACY) {
+ return Query.Type.ADVANCED;
+ } else if (providerQueryType == ProviderConfig.QueryType.PROGRAMMATIC) {
+ return Query.Type.PROGRAMMATIC;
+ } else if (providerQueryType == ProviderConfig.QueryType.YQL) {
+ return Query.Type.YQL;
+ } else {
+ throw new RuntimeException("Query type " + providerQueryType
+ + " unsupported.");
+ }
+ }
+
+ /**
+ * Serialize the query parameter for outgoing queries. For YQL+ queries,
+ * sources and fields will be set to all sources and all fields, to follow
+ * the behavior of other query types.
+ *
+ * @param query
+ * the current, outgoing query
+ * @return a string to include in an HTTP request
+ */
+ public String marshalQuery(Query query) {
+ if (queryType != Query.Type.YQL) {
+ return marshalQuery(query.getModel().getQueryTree());
+ }
+
+ Query workQuery = query.clone();
+ String error = QueryCanonicalizer.canonicalize(workQuery);
+ if (error != null) {
+ getLogger().log(LogLevel.WARNING,
+ "Could not normalize [" + query.toString() + "]: " + error);
+ // Just returning null here is the pattern from existing code...
+ return null;
+ }
+ chooseYqlSources(workQuery.getModel().getSources());
+ chooseYqlSummaryFields(workQuery.getPresentation().getSummaryFields());
+ return workQuery.yqlRepresentation(getSegmenterVersion(), false);
+ }
+
+ public String marshalQuery(QueryTree root) {
+ QueryCanonicalizer.QueryWrapper qw = new QueryCanonicalizer.QueryWrapper();
+ root = root.clone();
+ qw.setRoot(root.getRoot());
+ boolean could = QueryCanonicalizer.treeCanonicalize(qw, root.getRoot(),
+ null);
+ if (!could) {
+ return null;
+ }
+ return marshalRoot(qw.getRoot());
+ }
+
+ private String marshalRoot(Item root) {
+ switch (queryType) {
+ case ADVANCED:
+ QueryMarshaller marshaller = new QueryMarshaller();
+ return marshaller.marshal(root);
+ case PROGRAMMATIC:
+ return TextSerialize.serialize(root);
+ default:
+ throw new RuntimeException("Unsupported query type.");
+ }
+ }
+
+ private XMLReader getReader() {
+ XMLReader reader = readerHolder.get();
+ if (reader == null) {
+ reader = ResultBuilder.createParser();
+ readerHolder.set(reader);
+ }
+ return reader;
+ }
+
+ @Override
+ public void unmarshal(InputStream stream, long contentLength, Result result) {
+ ResultBuilder parser = new ResultBuilder(getReader());
+ Result mResult = parser.parse(new InputSource(stream),
+ result.getQuery());
+ result.mergeWith(mResult);
+ result.hits().addAll(mResult.hits().asUnorderedHits());
+ }
+
+ /** Returns the canonical Vespa ping URI, http://host:port/status.html */
+ @Override
+ public URI getPingURI(Connection connection) throws MalformedURLException,
+ URISyntaxException {
+ return new URL(getParameters().getSchema(), connection.getHost(),
+ connection.getPort(), "/status.html").toURI();
+ }
+
+ /**
+ * Get the segmenter version data used when creating YQL queries. Useful if
+ * overriding {@link #marshalQuery(Query)}.
+ *
+ * @return a tuple with the name of the segmenting engine in use, and its
+ * version
+ */
+ protected Tuple2<String, Version> getSegmenterVersion() {
+ return segmenterVersion;
+ }
+
+ /**
+ * Choose which source arguments to use for the external cluster when
+ * generating a YQL+ query string. This is called from
+ * {@link #marshalQuery(Query)}. The default implementation clears the set,
+ * i.e. requests all sources. Other implementations may modify the source
+ * set as they see fit, or simply do nothing.
+ *
+ * @param sources
+ * the set of source names to use for the outgoing query
+ */
+ protected void chooseYqlSources(Set<String> sources) {
+ sources.clear();
+ }
+
+ /**
+ * Choose which summary fields to request from the external cluster when
+ * generating a YQL+ query string. This is called from
+ * {@link #marshalQuery(Query)}. The default implementation clears the set,
+ * i.e. requests all fields. Other implementations may modify the summary
+ * field set as they see fit, or simply do nothing.
+ *
+ * @param summaryFields
+ * the set of source names to use for the outgoing query
+ */
+ protected void chooseYqlSummaryFields(Set<String> summaryFields) {
+ summaryFields.clear();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/federation/vespa/package-info.java b/container-search/src/main/java/com/yahoo/search/federation/vespa/package-info.java
new file mode 100644
index 00000000000..6a9f1decb21
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/federation/vespa/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.federation.vespa;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/Continuation.java b/container-search/src/main/java/com/yahoo/search/grouping/Continuation.java
new file mode 100644
index 00000000000..63139348ab3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/Continuation.java
@@ -0,0 +1,24 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping;
+
+import com.yahoo.search.grouping.vespa.ContinuationDecoder;
+
+/**
+ * <p>This class represents a piece of data stored by the grouping framework within a grouping result, which can
+ * subsequently be sent back along with the original request to navigate across a large result set. It is an opaque
+ * data object that is not intended to be human readable.</p>
+ *
+ * <p>To render a Cookie within a result set, you simply need to call {@link #toString()}.</p>
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class Continuation {
+
+ public static final String NEXT_PAGE = "next";
+ public static final String PREV_PAGE = "prev";
+ public static final String THIS_PAGE = "this";
+
+ public static Continuation fromString(String str) {
+ return ContinuationDecoder.decode(str);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/GroupingQueryParser.java b/container-search/src/main/java/com/yahoo/search/grouping/GroupingQueryParser.java
new file mode 100644
index 00000000000..39bdd48c05e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/GroupingQueryParser.java
@@ -0,0 +1,89 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping;
+
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.grouping.request.GroupingOperation;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.PhaseNames;
+
+import java.util.*;
+
+/**
+ * This searcher is responsible for turning the "select" parameter into a corresponding {@link GroupingRequest}. It will
+ * also parse any "timezone" parameter as the timezone for time expressions such as {@link
+ * com.yahoo.search.grouping.request.DayOfMonthFunction} and {@link com.yahoo.search.grouping.request.HourOfDayFunction}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+@After(PhaseNames.RAW_QUERY)
+@Before(PhaseNames.TRANSFORMED_QUERY)
+@Provides(GroupingQueryParser.SELECT_PARAMETER_PARSING)
+public class GroupingQueryParser extends Searcher {
+
+ public static final String SELECT_PARAMETER_PARSING = "SelectParameterParsing";
+ public static final CompoundName PARAM_CONTINUE = new CompoundName("continue");
+ public static final CompoundName PARAM_REQUEST = new CompoundName("select");
+ public static final CompoundName PARAM_TIMEZONE = new CompoundName("timezone");
+ private static final ThreadLocal<ZoneCache> zoneCache = new ThreadLocal<>();
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ String reqParam = query.properties().getString(PARAM_REQUEST);
+ if (reqParam == null) {
+ return execution.search(query);
+ }
+ List<Continuation> continuations = getContinuations(query.properties().getString(PARAM_CONTINUE));
+ TimeZone zone = getTimeZone(query.properties().getString(PARAM_TIMEZONE, "utc"));
+ for (GroupingOperation op : GroupingOperation.fromStringAsList(reqParam)) {
+ GroupingRequest grpRequest = GroupingRequest.newInstance(query);
+ grpRequest.setRootOperation(op);
+ grpRequest.setTimeZone(zone);
+ grpRequest.continuations().addAll(continuations);
+ }
+ return execution.search(query);
+ }
+
+ private List<Continuation> getContinuations(String param) {
+ if (param == null) {
+ return Collections.emptyList();
+ }
+ List<Continuation> ret = new LinkedList<>();
+ for (String str : param.split(" ")) {
+ ret.add(Continuation.fromString(str));
+ }
+ return ret;
+ }
+
+ private TimeZone getTimeZone(String name) {
+ ZoneCache cache = zoneCache.get();
+ if (cache == null) {
+ cache = new ZoneCache();
+ zoneCache.set(cache);
+ }
+ TimeZone timeZone = cache.get(name);
+ if (timeZone == null) {
+ timeZone = TimeZone.getTimeZone(name);
+ cache.put(name, timeZone);
+ }
+ return timeZone;
+ }
+
+ @SuppressWarnings("serial")
+ private static class ZoneCache extends LinkedHashMap<String, TimeZone> {
+
+ ZoneCache() {
+ super(16, 0.75f, true);
+ }
+
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String, TimeZone> entry) {
+ return size() > 128; // large enough to cache common cases
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/GroupingRequest.java b/container-search/src/main/java/com/yahoo/search/grouping/GroupingRequest.java
new file mode 100644
index 00000000000..8ace3ed72de
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/GroupingRequest.java
@@ -0,0 +1,164 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping;
+
+import com.yahoo.net.URI;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.request.GroupingOperation;
+import com.yahoo.search.grouping.result.Group;
+import com.yahoo.search.grouping.result.RootGroup;
+import com.yahoo.search.result.Hit;
+
+import java.util.*;
+
+/**
+ * An instance of this class represents one of many grouping requests that are attached to a {@link Query}. Use the
+ * factory method {@link #newInstance(com.yahoo.search.Query)} to create a new instance of this, then create and set the
+ * {@link GroupingOperation} using {@link #setRootOperation(GroupingOperation)}. Once the search returns, access the
+ * result {@link Group} using the {@link #getResultGroup(Result)} method.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class GroupingRequest {
+
+ private final static CompoundName PROP_REQUEST = new CompoundName(GroupingRequest.class.getName() + ".Request");
+ private final List<Continuation> continuations = new ArrayList<>();
+ private final int requestId;
+ private GroupingOperation root;
+ private TimeZone timeZone;
+ private URI resultId;
+
+ private GroupingRequest(int requestId) {
+ this.requestId = requestId;
+ }
+
+ /**
+ * Returns the id of this GroupingRequest. This id is injected into the {@link RootGroup} of the final result, and
+ * allows tracking of per-request meta data.
+ *
+ * @return The id of this.
+ */
+ public int getRequestId() {
+ return requestId;
+ }
+
+ /**
+ * Returns the root {@link GroupingOperation} that defines this request. As long as this remains unset, the request
+ * is void.
+ *
+ * @return The root operation.
+ */
+ public GroupingOperation getRootOperation() {
+ return root;
+ }
+
+ /**
+ * Sets the root {@link GroupingOperation} that defines this request. As long as this remains unset, the request is
+ * void.
+ *
+ * @param root The root operation to set.
+ * @return This, to allow chaining.
+ */
+ public GroupingRequest setRootOperation(GroupingOperation root) {
+ this.root = root;
+ return this;
+ }
+
+ /**
+ * Returns the {@link TimeZone} used when resolving time expressions such as {@link
+ * com.yahoo.search.grouping.request.DayOfMonthFunction} and {@link com.yahoo.search.grouping.request.HourOfDayFunction}.
+ *
+ * @return The time zone in use.
+ */
+ public TimeZone getTimeZone() {
+ return timeZone;
+ }
+
+ /**
+ * Sets the {@link TimeZone} used when resolving time expressions such as {@link
+ * com.yahoo.search.grouping.request.DayOfMonthFunction} and {@link com.yahoo.search.grouping.request.HourOfDayFunction}.
+ *
+ * @param timeZone The time zone to set.
+ * @return This, to allow chaining.
+ */
+ public GroupingRequest setTimeZone(TimeZone timeZone) {
+ this.timeZone = timeZone;
+ return this;
+ }
+
+ /**
+ * Returns the root result {@link RootGroup} that corresponds to this request. This is not available until the
+ * search returns. Because searchers are allowed to modify both {@link Result} and {@link Hit} objects freely, this
+ * method requires that you pass it the current {@link Result} object as argument.
+ *
+ * @param result The search result that contains the root group.
+ * @return The result {@link RootGroup} of this request, or null if not found.
+ */
+ public RootGroup getResultGroup(Result result) {
+ Hit root = result.hits().get(resultId, -1);
+ if (!(root instanceof RootGroup)) {
+ return null;
+ }
+ return (RootGroup)root;
+ }
+
+ /**
+ * Sets the result {@link RootGroup} of this request. This is used by the executing grouping searcher, and should
+ * not be called by a requesting searcher.
+ *
+ * @param group The result to set.
+ * @return This, to allow chaining.
+ */
+ public GroupingRequest setResultGroup(RootGroup group) {
+ this.resultId = group.getId();
+ return this;
+ }
+
+ /**
+ * Returns the list of {@link Continuation}s of this request. This is used by the executing grouping searcher to
+ * allow pagination of grouping results.
+ *
+ * @return The list of Continuations.
+ */
+ public List<Continuation> continuations() {
+ return continuations;
+ }
+
+ /**
+ * Creates and attaches a new instance of this class to the given {@link Query}. This is necessary to allow {@link
+ * #getRequests(Query)} to return all created requests.
+ *
+ * @param query The query to attach the request to.
+ * @return The created request.
+ */
+ public static GroupingRequest newInstance(Query query) {
+ List<GroupingRequest> lst = getRequests(query);
+ if (lst.isEmpty()) {
+ lst = new LinkedList<>();
+ query.properties().set(PROP_REQUEST, lst);
+ }
+ GroupingRequest ret = new GroupingRequest(lst.size());
+ lst.add(ret);
+ return ret;
+ }
+
+ /**
+ * Returns all instances of this class that have been attached to the given {@link Query}. If no requests have been
+ * attached to the {@link Query}, this method returns an empty list.
+ *
+ * @param query The query whose requests to return.
+ * @return The list of grouping requests.
+ */
+ @SuppressWarnings({ "unchecked" })
+ public static List<GroupingRequest> getRequests(Query query) {
+ Object lst = query.properties().get(PROP_REQUEST);
+ if (lst == null) {
+ return Collections.emptyList();
+ }
+ if (!(lst instanceof List)) {
+ throw new IllegalArgumentException("Expected " + GroupingRequest.class + ", got " + lst.getClass() + ".");
+ }
+ return (List<GroupingRequest>)lst;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/GroupingValidator.java b/container-search/src/main/java/com/yahoo/search/grouping/GroupingValidator.java
new file mode 100644
index 00000000000..1366fe1201b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/GroupingValidator.java
@@ -0,0 +1,85 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping;
+
+import com.google.inject.Inject;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.vespa.config.search.AttributesConfig;
+import com.yahoo.container.QrSearchersConfig;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.config.ClusterConfig;
+import com.yahoo.search.grouping.request.AttributeValue;
+import com.yahoo.search.grouping.request.ExpressionVisitor;
+import com.yahoo.search.grouping.request.GroupingExpression;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.PhaseNames;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static com.yahoo.search.grouping.GroupingQueryParser.SELECT_PARAMETER_PARSING;
+
+/**
+ * This searcher ensure that all {@link GroupingRequest} objects attached to a {@link Query} makes sense to the search
+ * cluster for which this searcher has been deployed. This searcher uses exceptions to signal invalid grouping
+ * requests.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+@Before(PhaseNames.BACKEND)
+@After(SELECT_PARAMETER_PARSING)
+@Provides(GroupingValidator.GROUPING_VALIDATED)
+public class GroupingValidator extends Searcher {
+
+ public static final String GROUPING_VALIDATED = "GroupingValidated";
+ public static final CompoundName PARAM_ENABLED = new CompoundName("validate_" + GroupingQueryParser.PARAM_REQUEST);
+ private final Set<String> attributeNames = new HashSet<>();
+ private final String clusterName;
+ private final boolean enabled;
+
+ /**
+ * Constructs a new instance of this searcher with the given component id and config.
+ *
+ * @param qrsConfig The shared config for all searchers.
+ * @param clusterConfig The config for the cluster that this searcher is deployed for.
+ */
+ @Inject
+ public GroupingValidator(QrSearchersConfig qrsConfig, ClusterConfig clusterConfig,
+ AttributesConfig attributesConfig) {
+ int clusterId = clusterConfig.clusterId();
+ QrSearchersConfig.Searchcluster.Indexingmode.Enum indexingMode = qrsConfig.searchcluster(clusterId).indexingmode();
+ enabled = (indexingMode != QrSearchersConfig.Searchcluster.Indexingmode.STREAMING);
+ clusterName = enabled ? qrsConfig.searchcluster(clusterId).name() : null;
+ for (AttributesConfig.Attribute attr : attributesConfig.attribute()) {
+ attributeNames.add(attr.name());
+ }
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ if (enabled && query.properties().getBoolean(PARAM_ENABLED, true)) {
+ ExpressionVisitor visitor = new MyVisitor();
+ for (GroupingRequest req : GroupingRequest.getRequests(query)) {
+ req.getRootOperation().visitExpressions(visitor);
+ }
+ }
+ return execution.search(query);
+ }
+
+ private class MyVisitor implements ExpressionVisitor {
+
+ @Override
+ public void visitExpression(GroupingExpression exp) {
+ if (exp instanceof AttributeValue) {
+ String name = ((AttributeValue)exp).getAttributeName();
+ if (!attributeNames.contains(name)) {
+ throw new UnavailableAttributeException(clusterName, name);
+ }
+ }
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/UnavailableAttributeException.java b/container-search/src/main/java/com/yahoo/search/grouping/UnavailableAttributeException.java
new file mode 100644
index 00000000000..7e147c88625
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/UnavailableAttributeException.java
@@ -0,0 +1,46 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping;
+
+/**
+ * This exception is thrown by the {@link GroupingValidator} if it a {@link GroupingRequest} contains a reference to an
+ * unavailable attribute.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+@SuppressWarnings("serial")
+public class UnavailableAttributeException extends RuntimeException {
+
+ private final String clusterName;
+ private final String attributeName;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param clusterName The name of the cluster for which the request is illegal.
+ * @param attributeName The name of the attribute which is referenced but not available.
+ */
+ public UnavailableAttributeException(String clusterName, String attributeName) {
+ super("Grouping request references attribute '" + attributeName + "' which is not available " +
+ "in cluster '" + clusterName + "'.");
+ this.clusterName = clusterName;
+ this.attributeName = attributeName;
+ }
+
+ /**
+ * Returns the name of the cluster for which the request is illegal.
+ *
+ * @return The cluster name.
+ */
+ public String getClusterName() {
+ return clusterName;
+ }
+
+ /**
+ * Returns the name of the attribute which is referenced but not available.
+ *
+ * @return The attribute name.
+ */
+ public String getAttributeName() {
+ return attributeName;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/UniqueGroupingSearcher.java b/container-search/src/main/java/com/yahoo/search/grouping/UniqueGroupingSearcher.java
new file mode 100644
index 00000000000..f4145a31f33
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/UniqueGroupingSearcher.java
@@ -0,0 +1,279 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping;
+
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.log.LogLevel;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.grouping.request.AllOperation;
+import com.yahoo.search.grouping.request.AttributeValue;
+import com.yahoo.search.grouping.request.CountAggregator;
+import com.yahoo.search.grouping.request.EachOperation;
+import com.yahoo.search.grouping.request.GroupingExpression;
+import com.yahoo.search.grouping.request.GroupingOperation;
+import com.yahoo.search.grouping.request.MaxAggregator;
+import com.yahoo.search.grouping.request.MinAggregator;
+import com.yahoo.search.grouping.request.NegFunction;
+import com.yahoo.search.grouping.request.SummaryValue;
+import com.yahoo.search.grouping.result.Group;
+import com.yahoo.search.grouping.result.GroupList;
+import com.yahoo.search.query.Sorting;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.result.HitOrderer;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.PhaseNames;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Logger;
+
+/**
+ * Implements 'unique' using a grouping expression.
+ *
+ * It doesn't work for multi-level sorting.
+ *
+ * @author andreer
+ */
+@After(PhaseNames.RAW_QUERY)
+@Before(PhaseNames.TRANSFORMED_QUERY)
+public class UniqueGroupingSearcher extends Searcher {
+
+ public static final CompoundName PARAM_UNIQUE = new CompoundName("unique");
+ private static final Logger log = Logger.getLogger(UniqueGroupingSearcher.class.getName());
+ private static final HitOrderer NOP_ORDERER = new HitOrderer() {
+
+ @Override
+ public void order(List<Hit> hits) {
+ // The order of hits is given by the grouping framework, and should not be re-ordered when we copy the hits
+ // from the groups to the base HitGroup in the result.
+ }
+ };
+ static final String LABEL_COUNT = "uniqueCount";
+ static final String LABEL_GROUPS = "uniqueGroups";
+ static final String LABEL_HITS = "uniqueHits";
+
+ /**
+ * Implements the deprecated "unique" api for deduplication by using grouping. We create a grouping expression on
+ * the field we wish to dedup on (which must be an attribute).
+ * Total hits is calculated using the new count unique groups functionality.
+ */
+ @Override
+ public Result search(Query query, Execution execution) {
+ // Determine if we should remove duplicates
+ String unique = query.properties().getString(PARAM_UNIQUE);
+ if (unique == null || unique.trim().isEmpty()) {
+ return execution.search(query);
+ }
+ query.trace("Performing deduping by attribute '" + unique + "'.", true, 3);
+ return dedupe(query, execution, unique);
+ }
+
+ /**
+ * Until we can use the grouping pagination features in 5.1, we'll have to support offset
+ * by simply requesting and discarding hit #0 up to hit #offset.
+ */
+ private static Result dedupe(Query query, Execution execution, String dedupField) {
+ Sorting sorting = query.getRanking().getSorting();
+ if (sorting != null && sorting.fieldOrders().size() > 1) {
+ query.trace("Can not use grouping for deduping with multi-level sorting.", 3);
+ // To support this we'd have to generate a grouping expression with as many levels
+ // as there are levels in the sort spec. This is probably too slow and costly that
+ // we'd ever want to actually use it (and a bit harder to implement as well).
+ return execution.search(query);
+ }
+
+ int hits = query.getHits();
+ int offset = query.getOffset();
+ int groupingHits = hits + offset;
+
+ GroupingRequest groupingRequest = GroupingRequest.newInstance(query);
+ groupingRequest.setRootOperation(
+ buildGroupingExpression(
+ dedupField,
+ groupingHits,
+ query.getPresentation().getSummary(),
+ sorting));
+
+ query.setHits(0);
+ query.setOffset(0);
+ Result result = execution.search(query);
+
+ query = result.getQuery(); // query could have changed further down in the chain
+ query.setHits(hits);
+ query.setOffset(offset);
+
+ Group root = groupingRequest.getResultGroup(result);
+ if (null == root) {
+ String msg = "Result group not found for deduping grouping request, returning empty result.";
+ query.trace(msg, 3);
+ log.log(LogLevel.WARNING, msg);
+ throw new IllegalStateException("Failed to produce deduped result set.");
+ }
+ result.hits().remove(root.getId().toString()); // hide our tracks
+
+ GroupList resultGroups = root.getGroupList(dedupField);
+ if (resultGroups == null) {
+ query.trace("Deduping grouping request returned no hits, returning empty result.", 3);
+ return result;
+ }
+
+ // Make sure that .addAll() doesn't re-order the hits we copy from the grouping
+ // framework. The groups are already in the order they should be.
+ result.hits().setOrderer(NOP_ORDERER);
+ result.hits().addAll(getRequestedHits(resultGroups, offset, hits));
+
+ Long countField = (Long) root.getField(LABEL_COUNT);
+ long count = countField != null ? countField : 0;
+ result.setTotalHitCount(count);
+
+ return result;
+ }
+
+ /**
+ * Create a hit ordering clause based on the sorting spec.
+ *
+ * @param sortingSpec A (single level!) sorting specification
+ * @return a grouping expression which produces a sortable value
+ */
+ private static List<GroupingExpression> createHitOrderingClause(Sorting sortingSpec) {
+ List<GroupingExpression> orderingClause = new ArrayList<>();
+ for (Sorting.FieldOrder fieldOrder : sortingSpec.fieldOrders()) {
+ Sorting.Order sortOrder = fieldOrder.getSortOrder();
+ switch (sortOrder) {
+ case ASCENDING:
+ case UNDEFINED:
+ // When we want ascending order, the hit with the smallest value should come first (and be surfaced).
+ orderingClause.add(new MinAggregator(new AttributeValue(fieldOrder.getFieldName())));
+ break;
+ case DESCENDING:
+ // When we sort in descending order, the hit with the largest value should come first (and be surfaced).
+ orderingClause.add(new NegFunction(new MaxAggregator(new AttributeValue(fieldOrder.getFieldName()))));
+ break;
+ default:
+ throw new UnsupportedOperationException("Can not handle sort order " + sortOrder + ".");
+ }
+ }
+ return orderingClause;
+ }
+
+ /**
+ * Create a hit ordering clause based on the sorting spec.
+ *
+ * @param sortingSpec A (single level!) sorting specification
+ * @return a grouping expression which produces a sortable value
+ */
+ private static GroupingExpression createGroupOrderingClause(Sorting sortingSpec) {
+ GroupingExpression groupingClause = null;
+ for (Sorting.FieldOrder fieldOrder : sortingSpec.fieldOrders()) {
+ Sorting.Order sortOrder = fieldOrder.getSortOrder();
+ switch (sortOrder) {
+ case ASCENDING:
+ case UNDEFINED:
+ groupingClause = new AttributeValue(fieldOrder.getFieldName());
+ break;
+ case DESCENDING:
+ // To sort descending, just take the negative. This is the most common case
+ groupingClause = new NegFunction(new AttributeValue(fieldOrder.getFieldName()));
+ break;
+ default:
+ throw new UnsupportedOperationException("Can not handle sort order " + sortOrder + ".");
+ }
+ }
+ return groupingClause;
+ }
+
+ /**
+ * Retrieve the actually unique hits from the grouping results.
+ *
+ * @param resultGroups the results of the dedup grouping expression.
+ * @param offset the requested offset. Hits before this are discarded.
+ * @param hits the requested number of hits. Hits in excess of this are discarded.
+ * @return A list of the actually requested hits, sorted as by the grouping expression.
+ */
+ private static List<Hit> getRequestedHits(GroupList resultGroups, int offset, int hits) {
+ List<Hit> receivedHits = getAllHitsFromGroupingResult(resultGroups);
+ if (receivedHits.size() <= offset) {
+ return Collections.emptyList(); // There weren't any hits as far out as requested.
+ }
+ int lastRequestedHit = Math.min(offset + hits, receivedHits.size());
+ return receivedHits.subList(offset, lastRequestedHit);
+ }
+
+ /**
+ * Get all the hits returned by the grouping request. This might be more or less than the user requested.
+ * This method handles the results from two different types of grouping expression, depending on whether
+ * sorting was used for the query or not.
+ *
+ * @param resultGroups The result group of the dedup grouping request
+ * @return A (correctly sorted) list of all the hits returned by the grouping expression.
+ */
+ private static List<Hit> getAllHitsFromGroupingResult(GroupList resultGroups) {
+ List<Hit> hits = new ArrayList<>(resultGroups.size());
+ for (Hit groupHit : resultGroups) {
+ Group group = (Group)groupHit;
+ GroupList sorted = group.getGroupList(LABEL_GROUPS);
+ if (sorted != null) {
+ group = (Group)sorted.iterator().next();
+ }
+ for (Hit hit : group.getHitList(LABEL_HITS)) {
+ hits.add(hit);
+ }
+ }
+ return hits;
+ }
+
+ static GroupingOperation buildGroupingExpression(String dedupField, int groupingHits, String summaryClass,
+ Sorting sortSpec) {
+ if (sortSpec != null) {
+ return buildGroupingExpressionWithSorting(dedupField, groupingHits, summaryClass, sortSpec);
+ } else {
+ return buildGroupingExpressionWithRanking(dedupField, groupingHits, summaryClass);
+ }
+ }
+
+ /**
+ * Create the grouping expression when ranking is used for ordering
+ * (which is the default for grouping expressions, so ranking is not explicitly mentioned).
+ * See unit test for examples
+ */
+ private static GroupingOperation buildGroupingExpressionWithRanking(String dedupField, int groupingHits,
+ String summaryClass) {
+ return new AllOperation()
+ .setGroupBy(new AttributeValue(dedupField))
+ .addOutput(new CountAggregator().setLabel(LABEL_COUNT))
+ .setMax(groupingHits)
+ .addChild(new EachOperation()
+ .setMax(1)
+ .addChild(new EachOperation()
+ .setLabel(LABEL_HITS)
+ .addOutput(summaryClass == null ? new SummaryValue() : new SummaryValue(summaryClass))));
+ }
+
+ /**
+ * Create the grouping expression when sorting is used for ordering
+ * This grouping expression is more complicated and probably quite a bit heavier to execute.
+ * See unit test for examples
+ */
+ private static GroupingOperation buildGroupingExpressionWithSorting(String dedupField, int groupingHits,
+ String summaryClass, Sorting sortSpec) {
+ return new AllOperation()
+ .setGroupBy(new AttributeValue(dedupField))
+ .addOutput(new CountAggregator().setLabel(LABEL_COUNT))
+ .setMax(groupingHits)
+ .addOrderBy(createHitOrderingClause(sortSpec))
+ .addChild(new EachOperation()
+ .addChild(new AllOperation()
+ .setGroupBy(createGroupOrderingClause(sortSpec))
+ .addOrderBy(createHitOrderingClause(sortSpec))
+ .setMax(1)
+ .addChild(new EachOperation()
+ .setLabel(LABEL_GROUPS)
+ .addChild(new EachOperation()
+ .setLabel(LABEL_HITS)
+ .addOutput(summaryClass == null ? new SummaryValue() : new SummaryValue(summaryClass))))));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/package-info.java b/container-search/src/main/java/com/yahoo/search/grouping/package-info.java
new file mode 100644
index 00000000000..f569115008a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.grouping;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AddFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AddFunction.java
new file mode 100644
index 00000000000..2f321a5854d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AddFunction.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents an add-function in a {@link GroupingExpression}. It evaluates to a number that equals the
+ * result of adding the results of all arguments together in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AddFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public AddFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private AddFunction(List<GroupingExpression> args) {
+ super("add", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static AddFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new AddFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AggregatorNode.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AggregatorNode.java
new file mode 100644
index 00000000000..0df204506c1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AggregatorNode.java
@@ -0,0 +1,53 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an aggregated value in a {@link GroupingExpression}. Because it operates on a list of data, it
+ * can not be used as a document-level expression (i.e. level 0, see {@link GroupingExpression#resolveLevel(int)}). The
+ * contained expression is evaluated at the level of the aggregator minus 1.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class AggregatorNode extends GroupingExpression {
+
+ private final GroupingExpression exp;
+
+ protected AggregatorNode(String image) {
+ super(image + "()");
+ this.exp = null;
+ }
+
+ protected AggregatorNode(String image, GroupingExpression exp) {
+ super(image + "(" + exp.toString() + ")");
+ this.exp = exp;
+ }
+
+ /**
+ * Returns the expression that this node aggregates on.
+ *
+ * @return The expression.
+ */
+ public GroupingExpression getExpression() {
+ return exp;
+ }
+
+ @Override
+ public void resolveLevel(int level) {
+ super.resolveLevel(level);
+ if (level < 1) {
+ throw new IllegalArgumentException("Expression '" + this + "' not applicable for " +
+ GroupingOperation.getLevelDesc(level) + ".");
+ }
+ if (exp != null) {
+ exp.resolveLevel(level - 1);
+ }
+ }
+
+ @Override
+ public void visit(ExpressionVisitor visitor) {
+ super.visit(visitor);
+ if (exp != null) {
+ exp.visit(visitor);
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AllOperation.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AllOperation.java
new file mode 100644
index 00000000000..e78be0c1c1a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AllOperation.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This is a grouping operation that processes the input list as a whole, as opposed to {@link EachOperation} which
+ * processes each element of that list separately.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AllOperation extends GroupingOperation {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public AllOperation() {
+ super("all");
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AndFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AndFunction.java
new file mode 100644
index 00000000000..3053153e5a3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AndFunction.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents an and-function in a {@link GroupingExpression}. It evaluates to a long that equals the result
+ * of and'ing the results of all arguments together in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AndFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a long.
+ * @param arg2 The second compulsory argument, must evaluate to a long.
+ * @param argN The optional arguments, must evaluate to a long.
+ */
+ public AndFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private AndFunction(List<GroupingExpression> args) {
+ super("and", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static AndFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new AndFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ArrayAtLookup.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ArrayAtLookup.java
new file mode 100644
index 00000000000..1e613066bd4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ArrayAtLookup.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * Represents access of array element in a document attribute in a {@link GroupingExpression}.
+ *
+ * The first argument should be the name of an array attribute in the
+ * input {@link com.yahoo.search.result.Hit}, while the second
+ * argument is evaluated as an integer and used as the index in that array.
+ * If the index argument is less than 0 returns the first array element;
+ * if the index is greater than or equal to size(array) returns the last array element;
+ * if the array is empty returns 0 (or NaN?).
+ * @author arnej27959
+ */
+@Beta
+public class ArrayAtLookup extends DocumentValue {
+
+ private final String attributeName;
+ private final GroupingExpression arg2;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param attributeName The attribute name to assign to this.
+ */
+ public ArrayAtLookup(String attributeName, GroupingExpression indexArg) {
+ super("array.at(" + attributeName + ", " + indexArg + ")");
+ this.attributeName = attributeName;
+ this.arg2 = indexArg;
+ }
+
+ /**
+ * Returns the name of the attribute to retrieve from the input hit.
+ *
+ * @return The attribute name.
+ */
+ public String getAttributeName() {
+ return attributeName;
+ }
+
+ /**
+ * get the expression to evaluate before indexing
+ * @return grouping expression argument
+ */
+ public GroupingExpression getIndexArgument() {
+ return arg2;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AttributeFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AttributeFunction.java
new file mode 100644
index 00000000000..c16903ddca8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AttributeFunction.java
@@ -0,0 +1,32 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document attribute function in a {@link GroupingExpression}. It evaluates to the value of the
+ * named attribute in the input {@link com.yahoo.search.result.Hit}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AttributeFunction extends DocumentValue {
+
+ private final String name;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param attributeName The attribute name to assign to this.
+ */
+ public AttributeFunction(String attributeName) {
+ super("attribute(" + attributeName + ")");
+ name = attributeName;
+ }
+
+ /**
+ * Returns the name of the attribute to retrieve from the input hit.
+ *
+ * @return The attribute name.
+ */
+ public String getAttributeName() {
+ return name;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AttributeValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AttributeValue.java
new file mode 100644
index 00000000000..135463bf108
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AttributeValue.java
@@ -0,0 +1,32 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document attribute value in a {@link GroupingExpression}. It evaluates to the value of the
+ * named attribute in the input {@link com.yahoo.search.result.Hit}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AttributeValue extends DocumentValue {
+
+ private final String name;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param attributeName The attribute name to assign to this.
+ */
+ public AttributeValue(String attributeName) {
+ super(attributeName);
+ name = attributeName;
+ }
+
+ /**
+ * Returns the name of the attribute to retrieve from the input hit.
+ *
+ * @return The attribute name.
+ */
+ public String getAttributeName() {
+ return name;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AvgAggregator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AvgAggregator.java
new file mode 100644
index 00000000000..749b419488f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AvgAggregator.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an average-aggregator in a {@link GroupingExpression}. It evaluates to the average value that
+ * the contained expression evaluated to over all the inputs.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AvgAggregator extends AggregatorNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to aggregate on.
+ */
+ public AvgAggregator(GroupingExpression exp) {
+ super("avg", exp);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/AvgFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/AvgFunction.java
new file mode 100644
index 00000000000..c0474064741
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/AvgFunction.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a min-function in a {@link GroupingExpression}. It evaluates to a number that equals the
+ * average of the results of all arguments.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class AvgFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public AvgFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private AvgFunction(List<GroupingExpression> args) {
+ super("avg", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static AvgFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new AvgFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/BooleanValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/BooleanValue.java
new file mode 100644
index 00000000000..c41cfa4c4f2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/BooleanValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a constant {@link Boolean} value in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class BooleanValue extends ConstantValue<Boolean> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The immutable value to assign to this.
+ */
+ public BooleanValue(Boolean value) {
+ super(value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/BucketResolver.java b/container-search/src/main/java/com/yahoo/search/grouping/request/BucketResolver.java
new file mode 100644
index 00000000000..735347cde87
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/BucketResolver.java
@@ -0,0 +1,121 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * This is a helper class for resolving buckets to a list of
+ * {@link GroupingExpression} objects. To resolve a list simply
+ * {@link #push(ConstantValue, boolean)} onto it, before calling
+ * {@link #resolve(GroupingExpression)} to retrieve the list of corresponding
+ * grouping expression object.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class BucketResolver {
+
+ private final List<BucketValue> buckets = new LinkedList<>();
+ private ConstantValue<?> prev = null;
+ private boolean previnclusive = false;
+ private int idx = 0;
+
+ /**
+ * Pushes the given expression onto this bucket resolver. Once all buckets have been pushed using this method, call
+ * {@link #resolve(GroupingExpression)} to retrieve to combined grouping expression.
+ *
+ * @param val The expression to push.
+ * @param inclusive Whether or not the value is inclusive or not.
+ * @throws IllegalArgumentException Thrown if the expression is incompatible.
+ */
+ public BucketResolver push(ConstantValue<?> val, boolean inclusive) {
+ if (prev == null) {
+ prev = val;
+ } else if (!(prev instanceof InfiniteValue || val instanceof InfiniteValue)
+ && !prev.getClass().equals(val.getClass())) {
+ throw new IllegalArgumentException("Bucket type mismatch, expected '" + prev.getClass().getSimpleName() +
+ "' got '" + val.getClass().getSimpleName() + "'.");
+ } else if (prev instanceof InfiniteValue && val instanceof InfiniteValue) {
+ throw new IllegalArgumentException("Bucket type mismatch, cannot both be infinity.");
+ }
+ if ((++idx % 2) == 0) {
+ ConstantValue<?> begin = previnclusive ? prev : nextValue(prev);
+ ConstantValue<?> end = inclusive ? nextValue(val) : val;
+ if (begin instanceof DoubleValue || end instanceof DoubleValue) {
+ buckets.add(new DoubleBucket(begin, end));
+ } else if (begin instanceof LongValue || end instanceof LongValue) {
+ buckets.add(new LongBucket(begin, end));
+ } else if (begin instanceof StringValue || end instanceof StringValue) {
+ buckets.add(new StringBucket(begin, end));
+ } else if (begin instanceof RawValue || end instanceof RawValue) {
+ buckets.add(new RawBucket(begin, end));
+ } else {
+ throw new UnsupportedOperationException("Bucket type '" + val.getClass() + "' not supported.");
+ }
+ }
+ prev = val;
+ previnclusive = inclusive;
+ return this;
+ }
+
+ /**
+ * Resolves and returns the list of grouping expressions that correspond to the previously pushed buckets.
+ *
+ * @param exp The expression to assign to the function.
+ * @return The list corresponding to the pushed buckets.
+ */
+ public PredefinedFunction resolve(GroupingExpression exp) {
+ if ((idx % 2) == 1) {
+ throw new IllegalStateException("Missing to-limit of last bucket.");
+ }
+ int len = buckets.size();
+ if (len == 0) {
+ throw new IllegalStateException("Expected at least one bucket, got none.");
+ }
+ ConstantValue<?> begin = buckets.get(0).getFrom();
+ ConstantValue<?> end = buckets.get(0).getTo();
+ if (begin instanceof DoubleValue || end instanceof DoubleValue) {
+ if (len == 1) {
+ return new DoublePredefined(exp, (DoubleBucket)buckets.get(0));
+ } else {
+ return new DoublePredefined(exp, (DoubleBucket)buckets.get(0),
+ buckets.subList(1, len).toArray(new DoubleBucket[len - 1]));
+ }
+ } else if (begin instanceof LongValue || end instanceof LongValue) {
+ if (len == 1) {
+ return new LongPredefined(exp, (LongBucket)buckets.get(0));
+ } else {
+ return new LongPredefined(exp, (LongBucket)buckets.get(0),
+ buckets.subList(1, len).toArray(new LongBucket[len - 1]));
+ }
+ } else if (begin instanceof StringValue || end instanceof StringValue) {
+ if (len == 1) {
+ return new StringPredefined(exp, (StringBucket)buckets.get(0));
+ } else {
+ return new StringPredefined(exp, (StringBucket)buckets.get(0),
+ buckets.subList(1, len).toArray(new StringBucket[len - 1]));
+ }
+ } else if (begin instanceof RawValue || end instanceof RawValue) {
+ if (len == 1) {
+ return new RawPredefined(exp, (RawBucket)buckets.get(0));
+ } else {
+ return new RawPredefined(exp, (RawBucket)buckets.get(0),
+ buckets.subList(1, len).toArray(new RawBucket[len - 1]));
+ }
+ }
+ throw new UnsupportedOperationException("Bucket type '" + begin.getClass() + "' not supported.");
+ }
+
+ private ConstantValue<?> nextValue(ConstantValue<?> value) {
+ if (value instanceof LongValue) {
+ return LongBucket.nextValue((LongValue)value);
+ } else if (value instanceof DoubleValue) {
+ return DoubleBucket.nextValue((DoubleValue)value);
+ } else if (value instanceof StringValue) {
+ return StringBucket.nextValue((StringValue)value);
+ } else if (value instanceof RawValue) {
+ return RawBucket.nextValue((RawValue)value);
+ }
+ return value;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/BucketValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/BucketValue.java
new file mode 100644
index 00000000000..858a44e2fe8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/BucketValue.java
@@ -0,0 +1,54 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a bucket in a {@link PredefinedFunction}. The generic T is the data type of the range values
+ * 'from' and 'to'. The range is inclusive-from and exclusive-to. All supported data types are represented as subclasses
+ * of this.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class BucketValue extends GroupingExpression implements Comparable<BucketValue> {
+
+ private final ConstantValue<?> from;
+ private final ConstantValue<?> to;
+ private final ConstantValueComparator comparator = new ConstantValueComparator();
+
+ protected BucketValue(ConstantValue<?> inclusiveFrom, ConstantValue<?> exclusiveTo) {
+ super("bucket[" + asImage(inclusiveFrom) + ", " + asImage(exclusiveTo) + ">");
+ if (comparator.compare(exclusiveTo, inclusiveFrom) < 0) {
+ throw new IllegalArgumentException("Bucket to-value can not be less than from-value.");
+ }
+ from = inclusiveFrom;
+ to = exclusiveTo;
+ }
+
+ /**
+ * Returns the inclusive-from value of this bucket.
+ *
+ * @return The from-value.
+ */
+ public ConstantValue<?> getFrom() {
+ return from;
+ }
+
+ /**
+ * Returns the exclusive-to value of this bucket.
+ *
+ * @return The to-value.
+ */
+ public ConstantValue<?> getTo() {
+ return to;
+ }
+
+ @Override
+ public int compareTo(BucketValue rhs) {
+ if (comparator.compare(to, rhs.from) <= 0) {
+ return -1;
+ }
+ if (comparator.compare(from, rhs.to) >= 0) {
+ return 1;
+ }
+ return 0;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/CatFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/CatFunction.java
new file mode 100644
index 00000000000..9bc276bda92
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/CatFunction.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a cat-function in a {@link GroupingExpression}. It evaluates to a byte array that equals the
+ * concatenation of the binary result of all arguments in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class CatFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument.
+ * @param arg2 The second compulsory argument.
+ * @param argN The optional arguments.
+ */
+ public CatFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private CatFunction(List<GroupingExpression> args) {
+ super("cat", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static CatFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new CatFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValue.java
new file mode 100644
index 00000000000..8b8d92b5ae8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValue.java
@@ -0,0 +1,29 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a constant value in a {@link GroupingExpression}. Because it does not operate on any input,
+ * this expression type can be used at any input level (see {@link GroupingExpression#resolveLevel(int)}). All supported
+ * data types are represented as subclasses of this.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+@SuppressWarnings("rawtypes")
+public abstract class ConstantValue<T extends Comparable> extends GroupingExpression {
+
+ private final T value;
+
+ protected ConstantValue(T value) {
+ super(asImage(value));
+ this.value = value;
+ }
+
+ /**
+ * Returns the constant value of this.
+ *
+ * @return The value.
+ */
+ public T getValue() {
+ return value;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValueComparator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValueComparator.java
new file mode 100644
index 00000000000..e8017bbb796
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ConstantValueComparator.java
@@ -0,0 +1,24 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Comparator;
+
+/**
+ * This class compares two constant values, and takes into account that one of
+ * the arguments may be the very special infinity value.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+@SuppressWarnings("rawtypes")
+public class ConstantValueComparator implements Comparator<ConstantValue> {
+ @SuppressWarnings("unchecked")
+ @Override
+ public int compare(ConstantValue lhs, ConstantValue rhs) {
+ // Run infinite comparison method if one of the arguments are infinite.
+ if (rhs instanceof InfiniteValue) {
+ return (-1 * rhs.getValue().compareTo(lhs));
+ }
+ return (lhs.getValue().compareTo(rhs.getValue()));
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/CountAggregator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/CountAggregator.java
new file mode 100644
index 00000000000..f54d92cdbf5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/CountAggregator.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an count-aggregator in a {@link GroupingExpression}. It evaluates to the number of elements
+ * there are in the input.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class CountAggregator extends AggregatorNode {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public CountAggregator() {
+ super("count");
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DateFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DateFunction.java
new file mode 100644
index 00000000000..3d416b31d95
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DateFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a timestamp-formatter function in a {@link GroupingExpression}. It evaluates to a string on the
+ * form "YYYY-MM-DD" of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DateFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public DateFunction(GroupingExpression exp) {
+ super("time.date", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfMonthFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfMonthFunction.java
new file mode 100644
index 00000000000..4ead68cc8f1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfMonthFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a day-of-month timestamp-function in a {@link GroupingExpression}. It evaluates to a long that
+ * equals the day of month (1-31) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DayOfMonthFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public DayOfMonthFunction(GroupingExpression exp) {
+ super("time.dayofmonth", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfWeekFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfWeekFunction.java
new file mode 100644
index 00000000000..f91344e2e7b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfWeekFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a day-of-week timestamp-function in a {@link GroupingExpression}. It evaluates to a long that
+ * equals the day of week (0 - 6) of the result of the argument, Monday being 0.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DayOfWeekFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public DayOfWeekFunction(GroupingExpression exp) {
+ super("time.dayofweek", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfYearFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfYearFunction.java
new file mode 100644
index 00000000000..20313864493
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DayOfYearFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a day-of-year timestamp-function in a {@link GroupingExpression}. It evaluates to a long that
+ * equals the day of year (0-365) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DayOfYearFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public DayOfYearFunction(GroupingExpression exp) {
+ super("time.dayofyear", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DebugWaitFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DebugWaitFunction.java
new file mode 100644
index 00000000000..c2f26e6b3b0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DebugWaitFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents debug_wait function in a {@link GroupingExpression}. For each hit evaluated,
+ * it waits for the time specified as the second argument. The third argument specifies if the wait
+ * should be a busy-wait or not. The first argument is then evaluated.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class DebugWaitFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, the expression to proxy.
+ * @param arg2 The second compulsory argument, must evaluate to a positive number.
+ * @param arg3 The third compulsory argument, specifying busy wait or not.
+ */
+ public DebugWaitFunction(GroupingExpression arg1, DoubleValue arg2, BooleanValue arg3) {
+ super("debugwait", Arrays.asList(arg1, arg2, arg3));
+ }
+
+ /**
+ * Returns the time to wait when evaluating this function.
+ *
+ * @return the number of seconds to wait.
+ */
+ public double getWaitTime() {
+ return ((DoubleValue)getArg(1)).getValue();
+ }
+
+ /**
+ * Returns whether or not the debug node should busy-wait.
+ *
+ * @return true if busy-wait, false if not.
+ */
+ public boolean getBusyWait() {
+ return ((BooleanValue)getArg(2)).getValue();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DivFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DivFunction.java
new file mode 100644
index 00000000000..9ed263362fa
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DivFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a div-function in a {@link GroupingExpression}. It evaluates to a number that equals the result
+ * of dividing the results of all arguments in the order they were given to the constructor (divide first argument by
+ * second, result by third, ...).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DivFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public DivFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private DivFunction(List<GroupingExpression> args) {
+ super("div", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static DivFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new DivFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DocIdNsSpecificValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DocIdNsSpecificValue.java
new file mode 100644
index 00000000000..02c8d66be5d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DocIdNsSpecificValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document id specific value in a {@link GroupingExpression}. It evaluates to the namespace-
+ * specific value of the document id of the input {@link com.yahoo.search.result.Hit}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DocIdNsSpecificValue extends DocumentValue {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public DocIdNsSpecificValue() {
+ super("docidnsspecific()");
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DocumentValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DocumentValue.java
new file mode 100644
index 00000000000..98d5a6fe21f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DocumentValue.java
@@ -0,0 +1,24 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document value in a {@link GroupingExpression}. As such, the subclasses of this can only be
+ * used as document-level expressions (i.e. level 0, see {@link GroupingExpression#resolveLevel(int)}).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class DocumentValue extends GroupingExpression {
+
+ protected DocumentValue(String image) {
+ super(image);
+ }
+
+ @Override
+ public void resolveLevel(int level) {
+ if (level != 0) {
+ throw new IllegalArgumentException("Expression '" + this + "' not applicable for " +
+ GroupingOperation.getLevelDesc(level) + ".");
+ }
+ super.resolveLevel(level);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DoubleBucket.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DoubleBucket.java
new file mode 100644
index 00000000000..4e12e96272e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DoubleBucket.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+import java.text.ChoiceFormat;
+
+/**
+ * This class represents a {@link Double} bucket in a {@link PredefinedFunction}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DoubleBucket extends BucketValue {
+
+ /**
+ * Returns the next distinct value.
+ *
+ * @param value The base value.
+ * @return the next value.
+ */
+ public static DoubleValue nextValue(DoubleValue value) {
+ return (new DoubleValue(ChoiceFormat.nextDouble(value.getValue())));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public DoubleBucket(double from, double to) {
+ super(new DoubleValue(from), new DoubleValue(to));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public DoubleBucket(ConstantValue<?> from, ConstantValue<?> to) {
+ super(from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DoublePredefined.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DoublePredefined.java
new file mode 100644
index 00000000000..59265359715
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DoublePredefined.java
@@ -0,0 +1,48 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a predefined bucket-function in a {@link GroupingExpression} for expressions that evaluate to a
+ * double.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DoublePredefined extends PredefinedFunction {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a double.
+ * @param arg1 The compulsory bucket.
+ * @param argN The optional buckets.
+ */
+ public DoublePredefined(GroupingExpression exp, DoubleBucket arg1, DoubleBucket... argN) {
+ this(exp, asList(arg1, argN));
+ }
+
+ private DoublePredefined(GroupingExpression exp, List<DoubleBucket> args) {
+ super(exp, args);
+ }
+
+ @Override
+ public DoubleBucket getBucket(int i) {
+ return (DoubleBucket)getArg(i + 1);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param exp The expression to evaluate, must evaluate to a double.
+ * @param args The buckets to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the list of buckets is empty.
+ */
+ public static DoublePredefined newInstance(GroupingExpression exp, List<DoubleBucket> args) {
+ if (args.isEmpty()) {
+ throw new IllegalArgumentException("Expected at least one bucket, got none.");
+ }
+ return new DoublePredefined(exp, args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/DoubleValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/DoubleValue.java
new file mode 100644
index 00000000000..682102533ff
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/DoubleValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a constant {@link Double} value in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DoubleValue extends ConstantValue<Double> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The immutable value to assign to this.
+ */
+ public DoubleValue(double value) {
+ super(value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/EachOperation.java b/container-search/src/main/java/com/yahoo/search/grouping/request/EachOperation.java
new file mode 100644
index 00000000000..12f6df1f497
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/EachOperation.java
@@ -0,0 +1,26 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This is a grouping operation that processes each element of the input list separately, as opposed to {@link
+ * AllOperation} which processes that list as a whole.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class EachOperation extends GroupingOperation {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public EachOperation() {
+ super("each");
+ }
+
+ @Override
+ public void resolveLevel(int level) {
+ if (level == 0) {
+ throw new IllegalArgumentException("Operation '" + this + "' can not operate on " + getLevelDesc(level) + ".");
+ }
+ super.resolveLevel(level - 1);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ExpressionVisitor.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ExpressionVisitor.java
new file mode 100644
index 00000000000..ba411ac45ce
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ExpressionVisitor.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This interface defines the necessary callback to recursively visit all {@link GroupingExpression} objects in a {@link
+ * GroupingOperation}. It is used by the {@link com.yahoo.search.grouping.GroupingValidator} to ensure that all
+ * referenced attributes are valid for the cluster being queried.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public interface ExpressionVisitor {
+
+ /**
+ * This method is called for every {@link GroupingExpression} object in the targeted {@link GroupingOperation}.
+ *
+ * @param exp The expression being visited.
+ */
+ public void visitExpression(GroupingExpression exp);
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/FixedWidthFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/FixedWidthFunction.java
new file mode 100644
index 00000000000..9ac3870718b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/FixedWidthFunction.java
@@ -0,0 +1,34 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a fixed-width bucket-function in a {@link GroupingExpression}. It maps the input into the given
+ * number of buckets by the result of the argument expression.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class FixedWidthFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ * @param width The width of each bucket.
+ */
+ public FixedWidthFunction(GroupingExpression exp, Number width) {
+ super("fixedwidth", Arrays.asList(exp, width instanceof Double ? new DoubleValue(width.doubleValue()) : new LongValue(width.longValue())));
+ }
+
+ /**
+ * Returns the number of buckets to divide the result into.
+ *
+ * @return The bucket count.
+ */
+ public Number getWidth() {
+ GroupingExpression w = getArg(1);
+ return (w instanceof LongValue) ? ((LongValue)w).getValue() : ((DoubleValue)w).getValue();
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/FunctionNode.java b/container-search/src/main/java/com/yahoo/search/grouping/request/FunctionNode.java
new file mode 100644
index 00000000000..3003ce69abe
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/FunctionNode.java
@@ -0,0 +1,78 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.*;
+
+/**
+ * This class represents a function in a {@link GroupingExpression}. Because it operate on other expressions (as opposed
+ * to {@link AggregatorNode} and {@link DocumentValue} that operate on inputs), this expression type can be used at any
+ * input level (see {@link GroupingExpression#resolveLevel(int)}).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class FunctionNode extends GroupingExpression implements Iterable<GroupingExpression> {
+
+ private final List<GroupingExpression> args = new ArrayList<>();
+
+ protected FunctionNode(String image, List<GroupingExpression> args) {
+ super(image + "(" + asString(args) + ")");
+ this.args.addAll(args);
+ }
+
+ /**
+ * Returns the number of arguments that were given to this function at construction.
+ *
+ * @return The argument count.
+ */
+ public int getNumArgs() {
+ return args.size();
+ }
+
+ /**
+ * Returns the argument at the given index.
+ *
+ * @param i The index of the argument to return.
+ * @return The argument at the given index.
+ * @throws IndexOutOfBoundsException If the index is out of range.
+ */
+ public GroupingExpression getArg(int i) {
+ return args.get(i);
+ }
+
+ @Override
+ public Iterator<GroupingExpression> iterator() {
+ return Collections.unmodifiableList(args).iterator();
+ }
+
+ @Override
+ public void resolveLevel(int level) {
+ super.resolveLevel(level);
+ for (GroupingExpression arg : args) {
+ arg.resolveLevel(level);
+ }
+ }
+
+ @Override
+ public void visit(ExpressionVisitor visitor) {
+ super.visit(visitor);
+ for (GroupingExpression arg : args) {
+ arg.visit(visitor);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ protected static <T> List<T> asList(T arg1, T... argN) {
+ return asList(Arrays.asList(arg1), Arrays.asList(argN));
+ }
+
+ @SuppressWarnings("unchecked")
+ protected static <T> List<T> asList(T arg1, T arg2, T... argN) {
+ return asList(Arrays.asList(arg1, arg2), Arrays.asList(argN));
+ }
+
+ protected static <T> List<T> asList(List<T> foo, List<T> bar) {
+ List<T> ret = new LinkedList<>(foo);
+ ret.addAll(bar);
+ return ret;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingExpression.java b/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingExpression.java
new file mode 100644
index 00000000000..6015557f81e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingExpression.java
@@ -0,0 +1,100 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import com.yahoo.javacc.UnicodeUtilities;
+
+import java.util.List;
+
+/**
+ * This class represents an expression in a {@link GroupingOperation}. You may manually construct this expression, or
+ * you may use the {@link com.yahoo.search.grouping.request.parser.GroupingParser} to generate one from a query-string.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class GroupingExpression extends GroupingNode {
+
+ private Integer level = null;
+
+ protected GroupingExpression(String image) {
+ super(image);
+ }
+
+ /**
+ * Resolves the conceptual level of this expression. This level represents the type of data that is consumed by this
+ * expression, where level 0 is a single hit, level 1 is a group, level 2 is a list of groups, and so forth. This
+ * method verifies the input level against the expression type, and recursively resolves the level of all argument
+ * expressions.
+ *
+ * @param level The level of the input data.
+ * @throws IllegalArgumentException Thrown if the level of this expression could not be resolved.
+ * @throws IllegalStateException Thrown if type failed to accept the number of arguments provided.
+ */
+ public void resolveLevel(int level) {
+ if (level < 0) {
+ throw new IllegalArgumentException("Expression '" + this + "' recurses through a single hit.");
+ }
+ this.level = level;
+ }
+
+ /**
+ * Returns the conceptual level of this expression.
+ *
+ * @return The level.
+ * @throws IllegalArgumentException Thrown if the level of this expression has not been resolved.
+ * @see #resolveLevel(int)
+ */
+ public int getLevel() {
+ if (level == null) {
+ throw new IllegalStateException("Level for expression '" + this + "' has not been resolved.");
+ }
+ return level;
+ }
+
+ /**
+ * Recursively calls {@link ExpressionVisitor#visitExpression(GroupingExpression)} for this expression and all of
+ * its argument expressions.
+ *
+ * @param visitor The visitor to call.
+ */
+ public void visit(ExpressionVisitor visitor) {
+ visitor.visitExpression(this);
+ }
+
+ /**
+ * Returns a string description of the given list of expressions. This is a comma-separated list of the expressions
+ * own {@link GroupingExpression#toString()} output.
+ *
+ * @param lst The list of expressions to output.
+ * @return The string description.
+ */
+ public static String asString(List<GroupingExpression> lst) {
+ StringBuilder ret = new StringBuilder();
+ for (int i = 0, len = lst.size(); i < len; ++i) {
+ ret.append(lst.get(i));
+ if (i < len - 1) {
+ ret.append(", ");
+ }
+ }
+ return ret.toString();
+ }
+
+ /**
+ * Returns a string representation of an object that can be used in the 'image' constructor argument of {@link
+ * GroupingNode}. This method ensures that strings are quoted, and that all complex characters are escaped.
+ *
+ * @param obj The object to output.
+ * @return The string representation.
+ */
+ public static String asImage(Object obj) {
+ if (!(obj instanceof String)) {
+ return obj.toString();
+ }
+ return UnicodeUtilities.quote((String)obj, '"');
+ }
+
+ @Override
+ public GroupingExpression setLabel(String label) {
+ super.setLabel(label);
+ return this;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingNode.java b/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingNode.java
new file mode 100644
index 00000000000..b400dfe5737
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingNode.java
@@ -0,0 +1,44 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This is the abstract super class of both {@link GroupingOperation} and {@link GroupingExpression}. All nodes can be
+ * assigned a {@link String} label which in turn can be used to identify the corresponding result objects.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class GroupingNode {
+
+ private final String image;
+ private String label = null;
+
+ protected GroupingNode(String image) {
+ this.image = image;
+ }
+
+ /**
+ * Returns the label assigned to this grouping expression.
+ *
+ * @return The label string.
+ */
+ public String getLabel() {
+ return label;
+ }
+
+ /**
+ * Assigns a label to this grouping expression. The label is applied to the results of this expression so that they
+ * can be identified by the caller when processing the output.
+ *
+ * @param str The label to assign to this.
+ * @return This, to allow chaining.
+ */
+ public GroupingNode setLabel(String str) {
+ label = str;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return image;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingOperation.java b/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingOperation.java
new file mode 100644
index 00000000000..d49713ba9f2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/GroupingOperation.java
@@ -0,0 +1,582 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import com.yahoo.collections.LazyMap;
+import com.yahoo.collections.LazySet;
+import com.yahoo.search.grouping.request.parser.GroupingParser;
+import com.yahoo.search.grouping.request.parser.GroupingParserInput;
+import com.yahoo.search.grouping.request.parser.ParseException;
+import com.yahoo.search.grouping.request.parser.TokenMgrError;
+
+import java.util.*;
+
+/**
+ * This class represents a single node in a grouping operation tree. You may manually construct this tree, or you may
+ * use the {@link #fromString(String)} method to generate one from a query-string. To execute, assign it to a {@link
+ * com.yahoo.search.grouping.GroupingRequest} using the {@link com.yahoo.search.grouping.GroupingRequest#setRootOperation(GroupingOperation)}
+ * method.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class GroupingOperation extends GroupingNode {
+
+ private final List<GroupingExpression> orderBy = new ArrayList<>();
+ private final List<GroupingExpression> outputs = new ArrayList<>();
+ private final List<GroupingOperation> children = new ArrayList<>();
+ private final Map<String, GroupingExpression> alias = LazyMap.newHashMap();
+ private final Set<String> hints = LazySet.newHashSet();
+
+ private GroupingExpression groupBy = null;
+ private GroupingOperation parent = null;
+ private String where = null;
+ private boolean forceSinglePass = false;
+ private double accuracy = 0.95;
+ private int precision = 0;
+ private int level = -1;
+ private int max = -1;
+
+ protected GroupingOperation(String image) {
+ super(image);
+ }
+
+ /**
+ * Registers an alias with this operation. An alias is made available to expressions in both this node and all child
+ * nodes.
+ *
+ * @param id The id of the alias to put.
+ * @param exp The expression to associate with the id.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation putAlias(String id, GroupingExpression exp) {
+ alias.put(id, exp);
+ return this;
+ }
+
+ /**
+ * Returns the alias associated with the given name. If no alias can be found in this node, this method queries its
+ * parent grouping node. If the alias still can not be found, this method returns null.
+ *
+ * @param id The id of the alias to return.
+ * @return The expression associated with the id.
+ */
+ public GroupingExpression getAlias(String id) {
+ if (alias.containsKey(id)) {
+ return alias.get(id);
+ } else if (parent != null) {
+ return parent.getAlias(id);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Adds a hint to this.
+ *
+ * @param hint The hint to add.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addHint(String hint) {
+ hints.add(hint);
+ return this;
+ }
+
+ /**
+ * Returns whether or not the given hint has been added to this.
+ *
+ * @param hint The hint to check for.
+ * @return True if the hint has been added.
+ */
+ public boolean containsHint(String hint) {
+ return hints.contains(hint);
+ }
+
+ /**
+ * Returns an immutable view to the hint list of this node.
+ *
+ * @return The list.
+ */
+ public Set<String> getHints() {
+ return Collections.unmodifiableSet(hints);
+ }
+
+ /**
+ * Adds a child grouping node to this. This will also set the parent of the child so that it points to this node.
+ *
+ * @param op The child node to add.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addChild(GroupingOperation op) {
+ op.parent = this;
+ children.add(op);
+ return this;
+ }
+
+ /**
+ * Convenience method to call {@link #addChild(GroupingOperation)} for each element in the given list.
+ *
+ * @param lst The list of operations to add.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addChildren(List<GroupingOperation> lst) {
+ for (GroupingOperation op : lst) {
+ addChild(op);
+ }
+ return this;
+ }
+
+ /**
+ * Returns the number of child operations of this.
+ *
+ * @return The child count.
+ */
+ public int getNumChildren() {
+ return children.size();
+ }
+
+ /**
+ * Returns the child operation at the given index.
+ *
+ * @param i The index of the child to return.
+ * @return The child at the given index.
+ * @throws IndexOutOfBoundsException If the index is out of range.
+ */
+ public GroupingOperation getChild(int i) {
+ return children.get(i);
+ }
+
+ /**
+ * Returns an immutable view to the child list of this node.
+ *
+ * @return The list.
+ */
+ public List<GroupingOperation> getChildren() {
+ return Collections.unmodifiableList(children);
+ }
+
+ /**
+ * Assigns an expressions as the group-by clause of this operation.
+ *
+ * @param exp The expression to assign to this.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation setGroupBy(GroupingExpression exp) {
+ groupBy = exp;
+ return this;
+ }
+
+ /**
+ * Returns the expression assigned as the group-by clause of this.
+ *
+ * @return The expression.
+ */
+ public GroupingExpression getGroupBy() {
+ return groupBy;
+ }
+
+ /**
+ * Returns the conceptual level of this node.
+ *
+ * @return The level, or -1 if not resolved.
+ * @see #resolveLevel(int)
+ */
+ public int getLevel() {
+ return level;
+ }
+
+ /**
+ * Resolves the conceptual level of this operation. This level represents the type of data that is consumed by this
+ * operation, where level 0 is a single hit, level 1 is a group, level 2 is a list of groups, and so forth. This
+ * method verifies the input level against the operation type, and recursively resolves the level of all argument
+ * expressions.
+ *
+ * @param level The level of the input data.
+ * @throws IllegalArgumentException Thrown if a contained expression is invalid for the given level.
+ */
+ public void resolveLevel(int level) {
+ if (groupBy != null) {
+ if (level == 0) {
+ throw new IllegalArgumentException(
+ "Operation '" + this + "' can not group " + getLevelDesc(level) + ".");
+ }
+ groupBy.resolveLevel(level - 1);
+ ++level;
+ }
+ if (hasMax()) {
+ if (level == 0) {
+ throw new IllegalArgumentException(
+ "Operation '" + this + "' can not apply max to " + getLevelDesc(level) + ".");
+ }
+ }
+ this.level = level;
+ for (GroupingExpression exp : outputs) {
+ exp.resolveLevel(level);
+ }
+ if (!orderBy.isEmpty()) {
+ if (level == 0) {
+ throw new IllegalArgumentException(
+ "Operation '" + this + "' can not order " + getLevelDesc(level) + ".");
+ }
+ for (GroupingExpression exp : orderBy) {
+ exp.resolveLevel(level - 1);
+ }
+ }
+ for (GroupingOperation child : children) {
+ child.resolveLevel(level);
+ }
+ }
+
+ public GroupingOperation setForceSinglePass(boolean forceSinglePass) {
+ this.forceSinglePass = forceSinglePass;
+ return this;
+ }
+
+ public boolean getForceSinglePass() {
+ return forceSinglePass;
+ }
+
+ /**
+ * Assigns the max clause of this. This is the maximum number of groups to return for this operation.
+ *
+ * @param max The expression to assign to this.
+ * @return This, to allow chaining.
+ * @see #setPrecision(int)
+ */
+ public GroupingOperation setMax(int max) {
+ this.max = max;
+ return this;
+ }
+
+ /**
+ * Returns the max clause of this.
+ *
+ * @return The expression.
+ * @see #setMax(int)
+ */
+ public int getMax() {
+ return max;
+ }
+
+ /**
+ * Indicates if the 'max' value has been set.
+ *
+ * @return true if max value is set.
+ */
+ public boolean hasMax() { return max >= 0; }
+
+ /**
+ * Assigns an accuracy value for this. This is a number between 0 and 1 describing the accuracy of the result, which
+ * again determines the speed of the grouping request. A low value will make sure the grouping operation runs fast,
+ * at the sacrifice if a (possible) imprecise result.
+ *
+ * @param accuracy The accuracy to assign to this.
+ * @return This, to allow chaining.
+ * @throws IllegalArgumentException If the accuracy is outside the allowed value range.
+ */
+ public GroupingOperation setAccuracy(double accuracy) {
+ if (accuracy > 1.0 || accuracy < 0.0) {
+ throw new IllegalArgumentException("Illegal accuracy '" + accuracy + "'. Must be between 0 and 1.");
+ }
+ this.accuracy = accuracy;
+ return this;
+ }
+
+ /**
+ * Return the accuracy of this.
+ *
+ * @return The accuracy value.
+ * @see #setAccuracy(double)
+ */
+ public double getAccuracy() {
+ return accuracy;
+ }
+
+ /**
+ * Adds an expression to the order-by clause of this operation.
+ *
+ * @param exp The expressions to add to this.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addOrderBy(GroupingExpression exp) {
+ orderBy.add(exp);
+ return this;
+ }
+
+ /**
+ * Convenience method to call {@link #addOrderBy(GroupingExpression)} for each element in the given list.
+ *
+ * @param lst The list of expressions to add.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addOrderBy(List<GroupingExpression> lst) {
+ for (GroupingExpression exp : lst) {
+ addOrderBy(exp);
+ }
+ return this;
+ }
+
+ /**
+ * Returns the number of expressions in the order-by clause of this.
+ *
+ * @return The expression count.
+ */
+ public int getNumOrderBy() {
+ return orderBy.size();
+ }
+
+ /**
+ * Returns the group-by expression at the given index.
+ *
+ * @param i The index of the expression to return.
+ * @return The expression at the given index.
+ * @throws IndexOutOfBoundsException If the index is out of range.
+ */
+ public GroupingExpression getOrderBy(int i) {
+ return orderBy.get(i);
+ }
+
+ /**
+ * Returns an immutable view to the order-by clause of this.
+ *
+ * @return The expression list.
+ */
+ public List<GroupingExpression> getOrderBy() {
+ return Collections.unmodifiableList(orderBy);
+ }
+
+ /**
+ * Adds an expression to the output clause of this operation.
+ *
+ * @param exp The expressions to add to this.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addOutput(GroupingExpression exp) {
+ outputs.add(exp);
+ return this;
+ }
+
+ /**
+ * Convenience method to call {@link #addOutput(GroupingExpression)} for each element in the given list.
+ *
+ * @param lst The list of expressions to add.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation addOutputs(List<GroupingExpression> lst) {
+ for (GroupingExpression exp : lst) {
+ addOutput(exp);
+ }
+ return this;
+ }
+
+ /**
+ * Returns the number of expressions in the output clause of this.
+ *
+ * @return The expression count.
+ */
+ public int getNumOutputs() {
+ return outputs.size();
+ }
+
+ /**
+ * Returns the output expression at the given index.
+ *
+ * @param i The index of the expression to return.
+ * @return The expression at the given index.
+ * @throws IndexOutOfBoundsException If the index is out of range.
+ */
+ public GroupingExpression getOutput(int i) {
+ return outputs.get(i);
+ }
+
+ /**
+ * Returns an immutable view to the output clause of this.
+ *
+ * @return The expression list.
+ */
+ public List<GroupingExpression> getOutputs() {
+ return Collections.unmodifiableList(outputs);
+ }
+
+ /**
+ * Assigns the precision clause of this. This is the number of intermediate groups returned from each search-node
+ * during expression evaluation to give the dispatch-node more data to consider when selecting the N groups that are
+ * to be evaluated further.
+ *
+ * @param precision The precision to set.
+ * @return This, to allow chaining.
+ * @see #setMax(int)
+ */
+ public GroupingOperation setPrecision(int precision) {
+ this.precision = precision;
+ return this;
+ }
+
+ /**
+ * Returns the precision clause of this.
+ *
+ * @return The precision.
+ */
+ public int getPrecision() {
+ return precision;
+ }
+
+ /**
+ * Assigns a string as the where clause of this operation.
+ *
+ * @param str The string to assign to this.
+ * @return This, to allow chaining.
+ */
+ public GroupingOperation setWhere(String str) {
+ where = str;
+ return this;
+ }
+
+ /**
+ * Returns the where clause assigned to this operation.
+ *
+ * @return The where clause.
+ */
+ public String getWhere() {
+ return where;
+ }
+
+ /**
+ * Recursively calls {@link GroupingExpression#visit(ExpressionVisitor)} on all {@link GroupingExpression} objects
+ * in this operation and in all of its child operations.
+ *
+ * @param visitor The visitor to call.
+ */
+ public void visitExpressions(ExpressionVisitor visitor) {
+ for (GroupingExpression exp : alias.values()) {
+ exp.visit(visitor);
+ }
+ for (GroupingExpression exp : outputs) {
+ exp.visit(visitor);
+ }
+ for (GroupingExpression exp : orderBy) {
+ exp.visit(visitor);
+ }
+ if (groupBy != null) {
+ groupBy.visit(visitor);
+ }
+ for (GroupingOperation op : children) {
+ op.visitExpressions(visitor);
+ }
+ }
+
+ @Override
+ public GroupingOperation setLabel(String label) {
+ super.setLabel(label);
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder ret = new StringBuilder();
+ ret.append(super.toString()).append("(");
+ if (groupBy != null) {
+ ret.append("group(").append(groupBy).append(") ");
+ }
+ for (String hint : hints) {
+ ret.append("hint(").append(hint).append(") ");
+ }
+ if (hasMax()) {
+ ret.append("max(").append(max).append(") ");
+ }
+ if (!orderBy.isEmpty()) {
+ ret.append("order(");
+ ret.append(GroupingExpression.asString(orderBy));
+ ret.append(") ");
+ }
+ if (!outputs.isEmpty()) {
+ ret.append("output(");
+ for (int i = 0, len = outputs.size(); i < len; ++i) {
+ GroupingExpression exp = outputs.get(i);
+ ret.append(exp);
+ String label = exp.getLabel();
+ if (label != null) {
+ ret.append(" as(").append(label).append(")");
+ }
+ if (i < len - 1) {
+ ret.append(", ");
+ }
+ }
+ ret.append(") ");
+ }
+ if (precision != 0) {
+ ret.append("precision(").append(precision).append(") ");
+ }
+ if (where != null) {
+ ret.append("where(").append(where).append(") ");
+ }
+ for (GroupingOperation child : children) {
+ ret.append(child).append(" ");
+ }
+ int len = ret.length();
+ if (ret.charAt(len - 1) == ' ') {
+ ret.setLength(len - 1);
+ }
+ ret.append(")");
+ String label = getLabel();
+ if (label != null) {
+ ret.append(" as(").append(label).append(")");
+ }
+ return ret.toString();
+ }
+
+ /**
+ * Returns a description of the given level. This allows for more descriptive errors being passed back to the user.
+ *
+ * @param level The level to describe.
+ * @return A description of the given level.
+ */
+ public static String getLevelDesc(int level) {
+ if (level <= 0) {
+ return "single hit";
+ } else if (level == 1) {
+ return "single group";
+ } else {
+ StringBuilder ret = new StringBuilder();
+ for (int i = 1; i < level; ++i) {
+ ret.append("list of ");
+ }
+ ret.append("groups");
+ return ret.toString();
+ }
+ }
+
+ /**
+ * Convenience method to call {@link #fromStringAsList(String)} and assert that the list contains exactly one
+ * grouping operation.
+ *
+ * @param str The string to parse.
+ * @return A grouping operation that corresponds to the string.
+ * @throws IllegalArgumentException Thrown if the string could not be parsed as a single operation.
+ */
+ public static GroupingOperation fromString(String str) {
+ List<GroupingOperation> lst = fromStringAsList(str);
+ if (lst.size() != 1) {
+ throw new IllegalArgumentException("Expected 1 operation, got " + lst.size() + ".");
+ }
+ return lst.get(0);
+ }
+
+ /**
+ * Parses the given string as a list of grouping operations. This method never returns null, it either returns a
+ * list of valid grouping requests or it throws an exception.
+ *
+ * @param str The string to parse.
+ * @return A list of grouping operations that corresponds to the string.
+ * @throws IllegalArgumentException Thrown if the string could not be parsed.
+ */
+ public static List<GroupingOperation> fromStringAsList(String str) {
+ if (str == null || str.trim().length() == 0) {
+ return Collections.emptyList();
+ }
+ GroupingParserInput input = new GroupingParserInput(str);
+ try {
+ return new GroupingParser(input).requestList();
+ } catch (ParseException | TokenMgrError e) {
+ throw new IllegalArgumentException(input.formatException(e.getMessage()), e);
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/HourOfDayFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/HourOfDayFunction.java
new file mode 100644
index 00000000000..5410ada6cf5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/HourOfDayFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents an hour-of-day timestamp-function in a {@link GroupingExpression}. It evaluates to a long that
+ * equals the hour of day (0-23) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class HourOfDayFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public HourOfDayFunction(GroupingExpression exp) {
+ super("time.hourofday", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/Infinite.java b/container-search/src/main/java/com/yahoo/search/grouping/request/Infinite.java
new file mode 100644
index 00000000000..dfee7d0e48a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/Infinite.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an Infinite value that may be used as a bucket
+ * size specifier.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+@SuppressWarnings("rawtypes")
+public class Infinite implements Comparable {
+ private final boolean negative;
+
+ /**
+ * Create an Infinite object with positive or negative sign.
+ * @param negative the signedness.
+ */
+ public Infinite(boolean negative) {
+ this.negative = negative;
+ }
+
+ /**
+ * Override the toString method in order to be re-parseable.
+ */
+ @Override
+ public String toString() {
+ return (negative ? "-inf" : "inf");
+ }
+
+ /**
+ * An infinity value is always less than or greater than.
+ */
+ @Override
+ public int compareTo(Object rhs) {
+ return (negative ? -1 : 1);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/InfiniteValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/InfiniteValue.java
new file mode 100644
index 00000000000..d20a9eb63f8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/InfiniteValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an infinite value in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class InfiniteValue extends ConstantValue<Infinite> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The immutable value to assign to this.
+ */
+ public InfiniteValue(Infinite value) {
+ super(value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/InterpolatedLookup.java b/container-search/src/main/java/com/yahoo/search/grouping/request/InterpolatedLookup.java
new file mode 100644
index 00000000000..a49ccdddbbc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/InterpolatedLookup.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * This class represents a lookup in a multivalue document
+ * attribute in a {@link GroupingExpression}. It takes the
+ * attribute (assumed to contain a sorted array) from the input
+ * {@link com.yahoo.search.result.Hit} and finds the index that
+ * the second (lookup) argument expression would have, with linear
+ * interpolation when the lookup argument is between two array
+ * element values.
+ *
+ * @author arnej27959
+ */
+@Beta
+public class InterpolatedLookup extends DocumentValue {
+
+ private final String attributeName;
+ private final GroupingExpression arg2;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param attributeName The attribute name the lookup should happen in
+ * @param lookupArg Expression giving a floating-point value for the lookup argument
+ */
+ public InterpolatedLookup(String attributeName, GroupingExpression lookupArg) {
+ super("interpolatedlookup(" + attributeName + ", " + lookupArg + ")");
+ this.attributeName = attributeName;
+ this.arg2 = lookupArg;
+ }
+
+ /**
+ * Get the name of the attribute to be retrieved from the input hit.
+ * @return The attribute name.
+ */
+ public String getAttributeName() {
+ return attributeName;
+ }
+
+ /**
+ * Get the expression that will be evaluated before lookup.
+ * @return grouping expression argument
+ */
+ public GroupingExpression getLookupArgument() {
+ return arg2;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/LongBucket.java b/container-search/src/main/java/com/yahoo/search/grouping/request/LongBucket.java
new file mode 100644
index 00000000000..566ca31cb2e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/LongBucket.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a {@link Long} bucket in a {@link PredefinedFunction}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class LongBucket extends BucketValue {
+
+ /**
+ * Gives the next distinct long value.
+ *
+ * @param value the base value.
+ * @return the nextt value.
+ */
+ public static LongValue nextValue(LongValue value) {
+ long v = value.getValue();
+ return new LongValue(v < Long.MAX_VALUE ? v + 1 : v);
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public LongBucket(long from, long to) {
+ super(new LongValue(from), new LongValue(to));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ @SuppressWarnings("rawtypes")
+ public LongBucket(ConstantValue from, ConstantValue to) {
+ super(from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/LongPredefined.java b/container-search/src/main/java/com/yahoo/search/grouping/request/LongPredefined.java
new file mode 100644
index 00000000000..486c8a9ddde
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/LongPredefined.java
@@ -0,0 +1,48 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a predefined bucket-function in a {@link GroupingExpression} for expressions that evaluate to a
+ * long.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class LongPredefined extends PredefinedFunction {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @param arg1 The compulsory bucket.
+ * @param argN The optional buckets.
+ */
+ public LongPredefined(GroupingExpression exp, LongBucket arg1, LongBucket... argN) {
+ this(exp, asList(arg1, argN));
+ }
+
+ private LongPredefined(GroupingExpression exp, List<LongBucket> args) {
+ super(exp, args);
+ }
+
+ @Override
+ public LongBucket getBucket(int i) {
+ return (LongBucket)getArg(i + 1);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @param args The buckets to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the list of buckets is empty.
+ */
+ public static LongPredefined newInstance(GroupingExpression exp, List<LongBucket> args) {
+ if (args.isEmpty()) {
+ throw new IllegalArgumentException("Expected at least one bucket, got none.");
+ }
+ return new LongPredefined(exp, args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/LongValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/LongValue.java
new file mode 100644
index 00000000000..62a0cb01f08
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/LongValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a constant {@link Long} value in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class LongValue extends ConstantValue<Long> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The immutable value to assign to this.
+ */
+ public LongValue(long value) {
+ super(value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathACosFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathACosFunction.java
new file mode 100644
index 00000000000..637e0fdf57e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathACosFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathACosFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathACosFunction(GroupingExpression exp) {
+ super("math.acos", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathACosHFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathACosHFunction.java
new file mode 100644
index 00000000000..aa5677d90d4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathACosHFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathACosHFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathACosHFunction(GroupingExpression exp) {
+ super("math.acosh", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathASinFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathASinFunction.java
new file mode 100644
index 00000000000..c4b9c7a62d6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathASinFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathASinFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathASinFunction(GroupingExpression exp) {
+ super("math.asin", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathASinHFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathASinHFunction.java
new file mode 100644
index 00000000000..f368aefe88a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathASinHFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathASinHFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathASinHFunction(GroupingExpression exp) {
+ super("math.asinh", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathATanFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathATanFunction.java
new file mode 100644
index 00000000000..ed9349c86e6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathATanFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathATanFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathATanFunction(GroupingExpression exp) {
+ super("math.atan", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathATanHFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathATanHFunction.java
new file mode 100644
index 00000000000..ebcfd1895fa
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathATanHFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathATanHFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathATanHFunction(GroupingExpression exp) {
+ super("math.atanh", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathCbrtFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathCbrtFunction.java
new file mode 100644
index 00000000000..78e2c3c9aa5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathCbrtFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathCbrtFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathCbrtFunction(GroupingExpression exp) {
+ super("math.cbrt", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathCosFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathCosFunction.java
new file mode 100644
index 00000000000..0ab35653607
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathCosFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathCosFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathCosFunction(GroupingExpression exp) {
+ super("math.cos", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathCosHFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathCosHFunction.java
new file mode 100644
index 00000000000..f4137c302e8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathCosHFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathCosHFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathCosHFunction(GroupingExpression exp) {
+ super("math.cosh", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathExpFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathExpFunction.java
new file mode 100644
index 00000000000..4be93d77c41
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathExpFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathExpFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathExpFunction(GroupingExpression exp) {
+ super("math.exp", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathFloorFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathFloorFunction.java
new file mode 100644
index 00000000000..f105332e352
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathFloorFunction.java
@@ -0,0 +1,16 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/** represents the math.floor(expression) function */
+public class MathFloorFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathFloorFunction(GroupingExpression exp) {
+ super("math.floor", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathFunctions.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathFunctions.java
new file mode 100644
index 00000000000..5fe5a971be9
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathFunctions.java
@@ -0,0 +1,69 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public abstract class MathFunctions {
+ /**
+ * Defines the different types of math functions that are available.
+ */
+ public enum Function {
+ EXP, // 0
+ POW, // 1
+ LOG, // 2
+ LOG1P, // 3
+ LOG10, // 4
+ SIN, // 5
+ ASIN, // 6
+ COS, // 7
+ ACOS, // 8
+ TAN, // 9
+ ATAN, // 10
+ SQRT, // 11
+ SINH, // 12
+ ASINH, // 13
+ COSH, // 14
+ ACOSH, // 15
+ TANH, // 16
+ ATANH, // 17
+ CBRT, // 18
+ HYPOT, // 19
+ FLOOR; // 20
+
+ static Function create(int tid) {
+ for(Function p : values()) {
+ if (tid == p.ordinal()) {
+ return p;
+ }
+ }
+ return null;
+ }
+ }
+ public static FunctionNode newInstance(Function type, GroupingExpression x, GroupingExpression y) {
+ switch (type) {
+ case EXP: return new MathExpFunction(x);
+ case POW: return new MathPowFunction(x, y);
+ case LOG: return new MathLogFunction(x);
+ case LOG1P: return new MathLog1pFunction(x);
+ case LOG10: return new MathLog10Function(x);
+ case SIN: return new MathSinFunction(x);
+ case ASIN: return new MathASinFunction(x);
+ case COS: return new MathCosFunction(x);
+ case ACOS: return new MathACosFunction(x);
+ case TAN: return new MathTanFunction(x);
+ case ATAN: return new MathATanFunction(x);
+ case SQRT: return new MathSqrtFunction(x);
+ case SINH: return new MathSinHFunction(x);
+ case ASINH: return new MathASinHFunction(x);
+ case COSH: return new MathCosHFunction(x);
+ case ACOSH: return new MathACosHFunction(x);
+ case TANH: return new MathTanHFunction(x);
+ case ATANH: return new MathATanHFunction(x);
+ case CBRT: return new MathCbrtFunction(x);
+ case HYPOT: return new MathHypotFunction(x, y);
+ case FLOOR: return new MathFloorFunction(x);
+ }
+ return null;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathHypotFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathHypotFunction.java
new file mode 100644
index 00000000000..777a94f9107
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathHypotFunction.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathHypotFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param x The expression to evaluate for x, double value will be requested.
+ * @param y The expression to evaluate for y exponent, double value will be requested.
+ */
+ public MathHypotFunction(GroupingExpression x, GroupingExpression y) {
+ super("math.hypot", Arrays.asList(x, y));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathLog10Function.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathLog10Function.java
new file mode 100644
index 00000000000..444ea7a7349
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathLog10Function.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathLog10Function extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathLog10Function(GroupingExpression exp) {
+ super("math.log10", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathLog1pFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathLog1pFunction.java
new file mode 100644
index 00000000000..3be6c799bf2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathLog1pFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathLog1pFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathLog1pFunction(GroupingExpression exp) {
+ super("math.log1p", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathLogFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathLogFunction.java
new file mode 100644
index 00000000000..4d3b43d45b0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathLogFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathLogFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathLogFunction(GroupingExpression exp) {
+ super("math.log", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathPowFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathPowFunction.java
new file mode 100644
index 00000000000..09a9a28cbb0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathPowFunction.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathPowFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param x The expression to evaluate for base, double value will be requested.
+ * @param y The expression to evaluate for the exponent, double value will be requested.
+ */
+ public MathPowFunction(GroupingExpression x, GroupingExpression y) {
+ super("math.pow", Arrays.asList(x,y));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathResolver.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathResolver.java
new file mode 100644
index 00000000000..9410c6ea347
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathResolver.java
@@ -0,0 +1,121 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Stack;
+
+/**
+ * This is a helper class for resolving arithmetic operations over {@link GroupingExpression} objects. To resolve an
+ * operation simply push operator-expression pairs onto it, before calling {@link #resolve()} to retrieve the single
+ * corresponding grouping expression object.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MathResolver {
+
+ public enum Type {
+
+ ADD(0, "+"),
+ SUB(1, "-"),
+ DIV(2, "/"),
+ MOD(3, "%"),
+ MUL(4, "*");
+
+ private final int pre;
+ private final String image;
+
+ private Type(int pre, String image) {
+ this.pre = pre;
+ this.image = image;
+ }
+ }
+
+ private final List<Item> items = new LinkedList<>();
+
+ /**
+ * Pushes the given operator-expression pair onto this math resolver. Once all pairs have been pushed using this
+ * method, call {@link #resolve()} to retrieve to combined grouping expression.
+ *
+ * @param type The operator that appears before the expression being pushed.
+ * @param exp The expression to push.
+ */
+ public void push(Type type, GroupingExpression exp) {
+ if (items.isEmpty() && type != Type.ADD) {
+ throw new IllegalArgumentException("First item in an arithmetic operation must be an addition.");
+ }
+ items.add(new Item(type, exp));
+ }
+
+ /**
+ * Converts the internal list of operator-expression pairs into a corresponding combined grouping expression. When
+ * this method returns there is no residue of the conversion, and this object can be reused.
+ *
+ * @return The grouping expression corresponding to the pushed arithmetic operations.
+ */
+ public GroupingExpression resolve() {
+ if (items.size() == 1) {
+ return items.remove(0).exp; // optimize common case
+ }
+ Stack<Item> stack = new Stack<>();
+ stack.push(items.remove(0));
+ while (!items.isEmpty()) {
+ Item item = items.remove(0);
+ while (stack.size() > 1 && stack.peek().type.pre >= item.type.pre) {
+ pop(stack);
+ }
+ stack.push(item);
+ }
+ while (stack.size() > 1) {
+ pop(stack);
+ }
+ return stack.remove(0).exp;
+ }
+
+ private void pop(Stack<Item> stack) {
+ Item rhs = stack.pop();
+ Item lhs = stack.peek();
+ switch (rhs.type) {
+ case ADD:
+ lhs.exp = new AddFunction(lhs.exp, rhs.exp);
+ break;
+ case DIV:
+ lhs.exp = new DivFunction(lhs.exp, rhs.exp);
+ break;
+ case MOD:
+ lhs.exp = new ModFunction(lhs.exp, rhs.exp);
+ break;
+ case MUL:
+ lhs.exp = new MulFunction(lhs.exp, rhs.exp);
+ break;
+ case SUB:
+ lhs.exp = new SubFunction(lhs.exp, rhs.exp);
+ break;
+ default:
+ throw new UnsupportedOperationException("Operator " + rhs.type + " not supported.");
+ }
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder ret = new StringBuilder();
+ for (int i = 0, len = items.size(); i < len; ++i) {
+ Item item = items.get(i);
+ if (i != 0) {
+ ret.append(" ").append(item.type.image).append(" ");
+ }
+ ret.append(item.exp.toString());
+ }
+ return ret.toString();
+ }
+
+ private static class Item {
+ final Type type;
+ GroupingExpression exp;
+
+ Item(Type type, GroupingExpression exp) {
+ this.type = type;
+ this.exp = exp;
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathSinFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathSinFunction.java
new file mode 100644
index 00000000000..66612e9d80a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathSinFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathSinFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathSinFunction(GroupingExpression exp) {
+ super("math.sin", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathSinHFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathSinHFunction.java
new file mode 100644
index 00000000000..79d260f51a0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathSinHFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathSinHFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathSinHFunction(GroupingExpression exp) {
+ super("math.sinh", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathSqrtFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathSqrtFunction.java
new file mode 100644
index 00000000000..18c9396dd12
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathSqrtFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathSqrtFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathSqrtFunction(GroupingExpression exp) {
+ super("math.sqrt", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathTanFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathTanFunction.java
new file mode 100644
index 00000000000..67db7a9d834
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathTanFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathTanFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathTanFunction(GroupingExpression exp) {
+ super("math.tan", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MathTanHFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MathTanHFunction.java
new file mode 100644
index 00000000000..e111c1199d7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MathTanHFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author balder
+ */
+public class MathTanHFunction extends FunctionNode {
+/**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, double value will be requested.
+ */
+ public MathTanHFunction(GroupingExpression exp) {
+ super("math.tanh", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MaxAggregator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MaxAggregator.java
new file mode 100644
index 00000000000..93f9e3c068e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MaxAggregator.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an maximum-aggregator in a {@link GroupingExpression}. It evaluates to the maximum value that
+ * the contained expression evaluated to over all the inputs.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MaxAggregator extends AggregatorNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to aggregate on.
+ */
+ public MaxAggregator(GroupingExpression exp) {
+ super("max", exp);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MaxFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MaxFunction.java
new file mode 100644
index 00000000000..da80a627c27
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MaxFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a max-function in a {@link GroupingExpression}. It evaluates to a number that equals the
+ * largest of the results of all arguments.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MaxFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public MaxFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private MaxFunction(List<GroupingExpression> args) {
+ super("max", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static MaxFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new MaxFunction(args);
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/Md5Function.java b/container-search/src/main/java/com/yahoo/search/grouping/request/Md5Function.java
new file mode 100644
index 00000000000..b2bd503c52f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/Md5Function.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents an md5-function in a {@link GroupingExpression}. It evaluates to a long that equals the md5 of
+ * the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class Md5Function extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ * @param numBits The number of bits of the md5 to include.
+ */
+ public Md5Function(GroupingExpression exp, int numBits) {
+ super("md5", Arrays.asList(exp, new LongValue(numBits)));
+ }
+
+ /**
+ * Returns the number of bits of the md5 to include in the evaluated result.
+ *
+ * @return The bit count.
+ */
+ public int getNumBits() {
+ return ((LongValue)getArg(1)).getValue().intValue();
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MinAggregator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MinAggregator.java
new file mode 100644
index 00000000000..5bb2f6675c8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MinAggregator.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an minimum-aggregator in a {@link GroupingExpression}. It evaluates to the minimum value that
+ * the contained expression evaluated to over all the inputs.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MinAggregator extends AggregatorNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to aggregate on.
+ */
+ public MinAggregator(GroupingExpression exp) {
+ super("min", exp);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MinFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MinFunction.java
new file mode 100644
index 00000000000..f66e23b87c0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MinFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a min-function in a {@link GroupingExpression}. It evaluates to a number that equals the
+ * smallest of the results of all arguments.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MinFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public MinFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private MinFunction(List<GroupingExpression> args) {
+ super("min", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static MinFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new MinFunction(args);
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MinuteOfHourFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MinuteOfHourFunction.java
new file mode 100644
index 00000000000..cb4b65f20b8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MinuteOfHourFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a minute-of-hour timestamp-function in a {@link GroupingExpression}. It evaluates to a long
+ * that equals the minute of hour (0-59) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MinuteOfHourFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public MinuteOfHourFunction(GroupingExpression exp) {
+ super("time.minuteofhour", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ModFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ModFunction.java
new file mode 100644
index 00000000000..d3d2502b714
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ModFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a mod-function in a {@link GroupingExpression}. It evaluates to a number that equals the result
+ * of mod'ing the results of all arguments in the order they were given to the constructor (modulo first argument by
+ * second, result by third, ...).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class ModFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public ModFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private ModFunction(List<GroupingExpression> args) {
+ super("mod", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static ModFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new ModFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MonthOfYearFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MonthOfYearFunction.java
new file mode 100644
index 00000000000..25f39892ee1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MonthOfYearFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a month-of-year timestamp-function in a {@link GroupingExpression}. It evaluates to a long that
+ * equals the month of year (1-12) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MonthOfYearFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public MonthOfYearFunction(GroupingExpression exp) {
+ super("time.monthofyear", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/MulFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/MulFunction.java
new file mode 100644
index 00000000000..d66361888b0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/MulFunction.java
@@ -0,0 +1,42 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a mul-function in a {@link GroupingExpression}. It evaluates to a number that equals the result
+ * of multiplying the results of all arguments together in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class MulFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public MulFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private MulFunction(List<GroupingExpression> args) {
+ super("mul", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static MulFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new MulFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/NegFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/NegFunction.java
new file mode 100644
index 00000000000..7ea2b3a788b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/NegFunction.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a negate-function in a {@link GroupingExpression}. It evaluates to a number that equals the
+ * negative of the results of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class NegFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a number.
+ */
+ public NegFunction(GroupingExpression exp) {
+ super("neg", Arrays.asList(exp));
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/NormalizeSubjectFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/NormalizeSubjectFunction.java
new file mode 100644
index 00000000000..1eaad713383
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/NormalizeSubjectFunction.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ */
+public class NormalizeSubjectFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a string.
+ */
+ public NormalizeSubjectFunction(GroupingExpression exp) {
+ super("normalizesubject", Arrays.asList(exp));
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/NowFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/NowFunction.java
new file mode 100644
index 00000000000..f876ee9a1df
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/NowFunction.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Collections;
+
+/**
+ * This class represents a now-function in a {@link GroupingExpression}. It evaluates to a long that equals the number
+ * of seconds since midnight, January 1, 1970 UTC.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class NowFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public NowFunction() {
+ super("now", Collections.<GroupingExpression>emptyList());
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/OrFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/OrFunction.java
new file mode 100644
index 00000000000..0a7ec7ecc06
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/OrFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents an or-function in a {@link GroupingExpression}. It evaluates to a long that equals the result
+ * of or'ing the results of all arguments together in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class OrFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a long.
+ * @param arg2 The second compulsory argument, must evaluate to a long.
+ * @param argN The optional arguments, must evaluate to a long.
+ */
+ public OrFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private OrFunction(List<GroupingExpression> args) {
+ super("or", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static OrFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new OrFunction(args);
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/PredefinedFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/PredefinedFunction.java
new file mode 100644
index 00000000000..b00ee97452c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/PredefinedFunction.java
@@ -0,0 +1,58 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * This class represents a predefined bucket-function in a {@link GroupingExpression}. It maps the input into one of the
+ * given buckets by the result of the argument expression.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class PredefinedFunction extends FunctionNode {
+
+ protected PredefinedFunction(GroupingExpression exp, List<? extends BucketValue> args) {
+ super("predefined", asList(exp, args));
+ Iterator<? extends BucketValue> it = args.iterator();
+ BucketValue prev = it.next();
+ while (it.hasNext()) {
+ BucketValue arg = it.next();
+ if (prev.compareTo(arg) >= 0) {
+ throw new IllegalArgumentException("Buckets must be monotonically increasing, got " + prev +
+ " before " + arg + ".");
+ }
+ prev = arg;
+ }
+ }
+
+ /**
+ * Returns the number of buckets to divide the result into.
+ *
+ * @return The bucket count.
+ */
+ public int getNumBuckets() {
+ return getNumArgs() - 1;
+ }
+
+ /**
+ * Returns the bucket at the given index.
+ *
+ * @param i The index of the bucket to return.
+ * @return The bucket at the given index.
+ * @throws IndexOutOfBoundsException If the index is out of range.
+ */
+ public BucketValue getBucket(int i) {
+ return (BucketValue)getArg(i + 1);
+ }
+
+ private static
+ List<GroupingExpression> asList(GroupingExpression exp, List<? extends BucketValue> args) {
+ List<GroupingExpression> ret = new LinkedList<>();
+ ret.add(exp);
+ ret.addAll(args);
+ return ret;
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/RawBucket.java b/container-search/src/main/java/com/yahoo/search/grouping/request/RawBucket.java
new file mode 100644
index 00000000000..d13b8b6ca67
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/RawBucket.java
@@ -0,0 +1,40 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a {@link RawValue} bucket in a {@link PredefinedFunction}.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class RawBucket extends BucketValue {
+
+ /**
+ * Get the next distinct value.
+ *
+ * @param value The base value.
+ * @return the next value.
+ */
+ public static RawValue nextValue(RawValue value) {
+ return new RawValue(value.getValue().clone().put((byte)0));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public RawBucket(RawBuffer from, RawBuffer to) {
+ super(new RawValue(from), new RawValue(to));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public RawBucket(ConstantValue<?> from, ConstantValue<?> to) {
+ super(from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/RawBuffer.java b/container-search/src/main/java/com/yahoo/search/grouping/request/RawBuffer.java
new file mode 100644
index 00000000000..00b9c899263
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/RawBuffer.java
@@ -0,0 +1,123 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.ArrayList;
+
+/**
+ * This class represents a buffer of byte values to be used as a backing buffer
+ * for raw buckets.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class RawBuffer implements Comparable<RawBuffer>, Cloneable {
+ private final ArrayList<Byte> buffer;
+
+ /**
+ * Create an empty buffer.
+ */
+ public RawBuffer() {
+ this.buffer = new ArrayList<>();
+ }
+
+ /**
+ * Create a buffer with initial content.
+ *
+ * @param buffer A buffer of values to be assigned this buffer.
+ */
+ public RawBuffer(ArrayList<Byte> buffer) {
+ this.buffer = buffer;
+ }
+
+ /**
+ * Create a buffer with initial content.
+ *
+ * @param bytes A buffer of bytes to be assigned this buffer.
+ */
+ public RawBuffer(byte[] bytes) {
+ buffer = new ArrayList<>();
+ put(bytes);
+ }
+
+ /**
+ * Insert a byte value into this buffer.
+ *
+ * @param value The value to add to the buffer.
+ * @return Reference to this.
+ */
+ public RawBuffer put(byte value) {
+ buffer.add(value);
+ return this;
+ }
+
+ /**
+ * Insert an array of byte values into this buffer.
+ *
+ * @param values The array to add to the buffer.
+ * @return Reference to this.
+ */
+ public RawBuffer put(byte[] values) {
+ for (int i = 0; i < values.length; i++) {
+ buffer.add(values[i]);
+ }
+ return this;
+ }
+
+ /**
+ * Create a copy of data in the internal buffer.
+ *
+ * @return A copy of the data.
+ */
+ public byte[] getBytes() {
+ byte[] ret = new byte[buffer.size()];
+ for (int i = 0; i < ret.length; i++) {
+ ret[i] = buffer.get(i);
+ }
+ return ret;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder s = new StringBuilder();
+ s.append("{");
+ for (int i = 0; i < buffer.size(); i++) {
+ s.append(buffer.get(i));
+ if (i < buffer.size() - 1) {
+ s.append(",");
+ }
+ }
+ s.append("}");
+ return s.toString();
+ }
+
+ @Override
+ public RawBuffer clone() {
+ return new RawBuffer(new ArrayList<>(buffer));
+ }
+
+ @Override
+ public int compareTo(RawBuffer rhs) {
+ Byte[] my = buffer.toArray(new Byte[0]);
+ Byte[] their = rhs.buffer.toArray(new Byte[0]);
+ for (int i = 0; i < my.length && i < their.length; i++) {
+ if (my[i] < their[i]) {
+ return -1;
+ } else if (my[i] > their[i]) {
+ return 1;
+ }
+ }
+ return (my.length < their.length ? -1 : (my.length > their.length ? 1 : 0));
+ }
+
+ @Override
+ public int hashCode() {
+ return buffer.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object rhs) {
+ if (rhs instanceof RawBuffer) {
+ return (compareTo((RawBuffer)rhs) == 0);
+ }
+ return false;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/RawPredefined.java b/container-search/src/main/java/com/yahoo/search/grouping/request/RawPredefined.java
new file mode 100644
index 00000000000..c2650346231
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/RawPredefined.java
@@ -0,0 +1,48 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a predefined bucket-function in a {@link GroupingExpression} for expressions that evaluate to a
+ * raw.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class RawPredefined extends PredefinedFunction {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a string.
+ * @param arg1 The compulsory bucket.
+ * @param argN The optional buckets.
+ */
+ public RawPredefined(GroupingExpression exp, RawBucket arg1, RawBucket... argN) {
+ this(exp, asList(arg1, argN));
+ }
+
+ private RawPredefined(GroupingExpression exp, List<RawBucket> args) {
+ super(exp, args);
+ }
+
+ @Override
+ public RawBucket getBucket(int i) {
+ return (RawBucket)getArg(i + 1);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param exp The expression to evaluate, must evaluate to a string.
+ * @param args The buckets to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the list of buckets is empty.
+ */
+ public static RawPredefined newInstance(GroupingExpression exp, List<RawBucket> args) {
+ if (args.isEmpty()) {
+ throw new IllegalArgumentException("Expected at least one bucket, got none.");
+ }
+ return new RawPredefined(exp, args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/RawValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/RawValue.java
new file mode 100644
index 00000000000..a04944d7897
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/RawValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a raw value in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class RawValue extends ConstantValue<RawBuffer> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The immutable value to assign to this.
+ */
+ public RawValue(RawBuffer value) {
+ super(value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/RelevanceValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/RelevanceValue.java
new file mode 100644
index 00000000000..8a5d4dc75d1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/RelevanceValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document relevance score in a {@link GroupingExpression}. It evaluates to the relevance of
+ * the input {@link com.yahoo.search.result.Hit}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class RelevanceValue extends DocumentValue {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public RelevanceValue() {
+ super("relevance()");
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ReverseFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ReverseFunction.java
new file mode 100644
index 00000000000..274bb20c9f7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ReverseFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a reverse-function in a {@link GroupingExpression}. It evaluates to a list that equals the list
+ * result of the argument, sorted in descending order.
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class ReverseFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a list.
+ */
+ public ReverseFunction(GroupingExpression exp) {
+ super("reverse", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/SecondOfMinuteFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/SecondOfMinuteFunction.java
new file mode 100644
index 00000000000..9443f862a16
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/SecondOfMinuteFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a second-of-minute timestamp-function in a {@link GroupingExpression}. It evaluates to a long
+ * that equals the second of minute (0-59) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class SecondOfMinuteFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public SecondOfMinuteFunction(GroupingExpression exp) {
+ super("time.secondofminute", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/SizeFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/SizeFunction.java
new file mode 100644
index 00000000000..d445007a039
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/SizeFunction.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a size-function in a {@link GroupingExpression}. It evaluates to a number that equals the
+ * number of elements in the result of the argument (e.g. the number of elements in an array).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class SizeFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ */
+ public SizeFunction(GroupingExpression exp) {
+ super("size", Arrays.asList(exp));
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/SortFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/SortFunction.java
new file mode 100644
index 00000000000..2a8845f9847
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/SortFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a sort-function in a {@link GroupingExpression}. It evaluates to a list that equals the list
+ * result of the argument, sorted in ascending order.
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class SortFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a list.
+ */
+ public SortFunction(GroupingExpression exp) {
+ super("sort", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/StrCatFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/StrCatFunction.java
new file mode 100644
index 00000000000..455f9dee917
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/StrCatFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a strcat-function in a {@link GroupingExpression}. It evaluates to a string that equals the
+ * contatenation of the string results of all arguments in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StrCatFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a string.
+ * @param arg2 The second compulsory argument, must evaluate to a string.
+ * @param argN The optional arguments, must evaluate to a string.
+ */
+ public StrCatFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private StrCatFunction(List<GroupingExpression> args) {
+ super("strcat", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static StrCatFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new StrCatFunction(args);
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/StrLenFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/StrLenFunction.java
new file mode 100644
index 00000000000..2ef53f53bf2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/StrLenFunction.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a strcat-function in a {@link GroupingExpression}. It evaluates to a long that equals the
+ * number of bytes in the string result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StrLenFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a string.
+ */
+ public StrLenFunction(GroupingExpression exp) {
+ super("strlen", Arrays.asList(exp));
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/StringBucket.java b/container-search/src/main/java/com/yahoo/search/grouping/request/StringBucket.java
new file mode 100644
index 00000000000..34c7b9f526a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/StringBucket.java
@@ -0,0 +1,40 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a {@link String} bucket in a {@link PredefinedFunction}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StringBucket extends BucketValue {
+
+ /**
+ * Get the next distinct value.
+ *
+ * @param value The base value.
+ * @return the next value.
+ */
+ public static StringValue nextValue(StringValue value) {
+ return new StringValue(value.getValue() + " ");
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public StringBucket(String from, String to) {
+ super(new StringValue(from), new StringValue(to));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The from-value to assign to this.
+ * @param to The to-value to assign to this.
+ */
+ public StringBucket(ConstantValue<?> from, ConstantValue<?> to) {
+ super(from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/StringPredefined.java b/container-search/src/main/java/com/yahoo/search/grouping/request/StringPredefined.java
new file mode 100644
index 00000000000..d3a469fdd7e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/StringPredefined.java
@@ -0,0 +1,48 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a predefined bucket-function in a {@link GroupingExpression} for expressions that evaluate to a
+ * string.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StringPredefined extends PredefinedFunction {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a string.
+ * @param arg1 The compulsory bucket.
+ * @param argN The optional buckets.
+ */
+ public StringPredefined(GroupingExpression exp, StringBucket arg1, StringBucket... argN) {
+ this(exp, asList(arg1, argN));
+ }
+
+ private StringPredefined(GroupingExpression exp, List<StringBucket> args) {
+ super(exp, args);
+ }
+
+ @Override
+ public StringBucket getBucket(int i) {
+ return (StringBucket)getArg(i + 1);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param exp The expression to evaluate, must evaluate to a string.
+ * @param args The buckets to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the list of buckets is empty.
+ */
+ public static StringPredefined newInstance(GroupingExpression exp, List<StringBucket> args) {
+ if (args.isEmpty()) {
+ throw new IllegalArgumentException("Expected at least one bucket, got none.");
+ }
+ return new StringPredefined(exp, args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/StringValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/StringValue.java
new file mode 100644
index 00000000000..87e818368d6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/StringValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a constant {@link String} value in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StringValue extends ConstantValue<String> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The immutable value to assign to this.
+ */
+ public StringValue(String value) {
+ super(value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/SubFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/SubFunction.java
new file mode 100644
index 00000000000..15e05c50f63
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/SubFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents a div-function in a {@link GroupingExpression}. It evaluates to a number that equals the result
+ * of subtracting the results of all arguments in the order they were given to the constructor (subtract second argument
+ * from first, third from result, ...).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class SubFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a number.
+ * @param arg2 The second compulsory argument, must evaluate to a number.
+ * @param argN The optional arguments, must evaluate to a number.
+ */
+ public SubFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private SubFunction(List<GroupingExpression> args) {
+ super("sub", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static SubFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new SubFunction(args);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/SumAggregator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/SumAggregator.java
new file mode 100644
index 00000000000..1ace1cfbba2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/SumAggregator.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an sum-aggregator in a {@link GroupingExpression}. It evaluates to the sum of the values that
+ * the contained expression evaluated to over all the inputs.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class SumAggregator extends AggregatorNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to aggregate on.
+ */
+ public SumAggregator(GroupingExpression exp) {
+ super("sum", exp);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/SummaryValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/SummaryValue.java
new file mode 100644
index 00000000000..72e4c6662d3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/SummaryValue.java
@@ -0,0 +1,40 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document summary in a {@link GroupingExpression}. It evaluates to the summary of the input
+ * {@link com.yahoo.search.result.Hit} that corresponds to the named summary class.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class SummaryValue extends DocumentValue {
+
+ private final String name;
+
+ /**
+ * Constructs a new instance of this class, using the default summary class.
+ */
+ public SummaryValue() {
+ super("summary()");
+ name = null;
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param summaryName The name of the summary class to assign to this.
+ */
+ public SummaryValue(String summaryName) {
+ super("summary(" + summaryName + ")");
+ name = summaryName;
+ }
+
+ /**
+ * Returns the name of the summary class used to retrieve the hit from the search node.
+ *
+ * @return The summary name.
+ */
+ public String getSummaryName() {
+ return name;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/TimeFunctions.java b/container-search/src/main/java/com/yahoo/search/grouping/request/TimeFunctions.java
new file mode 100644
index 00000000000..bde1c5831b5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/TimeFunctions.java
@@ -0,0 +1,148 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This abstract class is a factory for timestamp functions in a {@link GroupingExpression}. Apart from offering
+ * per-function factory methods, this class also contains a {@link #newInstance(com.yahoo.search.grouping.request.TimeFunctions.Type,
+ * GroupingExpression)} method which is useful for runtime construction of grouping requests.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class TimeFunctions {
+
+ /**
+ * Defines the different types of timestamps-functions that are available.
+ */
+ public enum Type {
+ DATE,
+ DAY_OF_MONTH,
+ DAY_OF_WEEK,
+ DAY_OF_YEAR,
+ HOUR_OF_DAY,
+ MINUTE_OF_HOUR,
+ MONTH_OF_YEAR,
+ SECOND_OF_MINUTE,
+ YEAR
+ }
+
+ /**
+ * Creates a new timestamp-function of the specified type for the given {@link GroupingExpression}.
+ *
+ * @param type The type of function to create.
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static FunctionNode newInstance(Type type, GroupingExpression exp) {
+ switch (type) {
+ case DATE:
+ return newDate(exp);
+ case DAY_OF_MONTH:
+ return newDayOfMonth(exp);
+ case DAY_OF_WEEK:
+ return newDayOfWeek(exp);
+ case DAY_OF_YEAR:
+ return newDayOfYear(exp);
+ case HOUR_OF_DAY:
+ return newHourOfDay(exp);
+ case MINUTE_OF_HOUR:
+ return newMinuteOfHour(exp);
+ case MONTH_OF_YEAR:
+ return newMonthOfYear(exp);
+ case SECOND_OF_MINUTE:
+ return newSecondOfMinute(exp);
+ case YEAR:
+ return newYear(exp);
+ }
+ throw new UnsupportedOperationException("Time function '" + type + "' not supported.");
+ }
+
+ /**
+ * Creates a new instance of {@link DateFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static DateFunction newDate(GroupingExpression exp) {
+ return new DateFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link DayOfMonthFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static DayOfMonthFunction newDayOfMonth(GroupingExpression exp) {
+ return new DayOfMonthFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link DayOfWeekFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static DayOfWeekFunction newDayOfWeek(GroupingExpression exp) {
+ return new DayOfWeekFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link DayOfYearFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static DayOfYearFunction newDayOfYear(GroupingExpression exp) {
+ return new DayOfYearFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link HourOfDayFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static HourOfDayFunction newHourOfDay(GroupingExpression exp) {
+ return new HourOfDayFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link MinuteOfHourFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static MinuteOfHourFunction newMinuteOfHour(GroupingExpression exp) {
+ return new MinuteOfHourFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link MonthOfYearFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static MonthOfYearFunction newMonthOfYear(GroupingExpression exp) {
+ return new MonthOfYearFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link SecondOfMinuteFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static SecondOfMinuteFunction newSecondOfMinute(GroupingExpression exp) {
+ return new SecondOfMinuteFunction(exp);
+ }
+
+ /**
+ * Creates a new instance of {@link YearFunction} for the given {@link GroupingExpression}.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ * @return The created function node.
+ */
+ public static YearFunction newYear(GroupingExpression exp) {
+ return new YearFunction(exp);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ToDoubleFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ToDoubleFunction.java
new file mode 100644
index 00000000000..8eab2af8691
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ToDoubleFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a todouble-function in a {@link GroupingExpression}. It converts the result of the argument to
+ * a double. If the argument can not be converted, this function returns 0.
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class ToDoubleFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ */
+ public ToDoubleFunction(GroupingExpression exp) {
+ super("todouble", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ToLongFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ToLongFunction.java
new file mode 100644
index 00000000000..c47a043eea0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ToLongFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a tolong-function in a {@link GroupingExpression}. It converts the result of the argument to a
+ * long. If the argument can not be converted, this function returns 0.
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class ToLongFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ */
+ public ToLongFunction(GroupingExpression exp) {
+ super("tolong", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ToRawFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ToRawFunction.java
new file mode 100644
index 00000000000..d1ba3afa28c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ToRawFunction.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a toraw-function in a {@link GroupingExpression}. It
+ * converts the result of the argument to a raw type. If the argument can not
+ * be converted, this function returns null.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class ToRawFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ */
+ public ToRawFunction(GroupingExpression exp) {
+ super("toraw", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ToStringFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ToStringFunction.java
new file mode 100644
index 00000000000..364d9e5064d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ToStringFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a tolong-function in a {@link GroupingExpression}. It converts the result of the argument to a
+ * long. If the argument can not be converted, this function returns 0.
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class ToStringFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ */
+ public ToStringFunction(GroupingExpression exp) {
+ super("tostring", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/UcaFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/UcaFunction.java
new file mode 100644
index 00000000000..2e23f41f139
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/UcaFunction.java
@@ -0,0 +1,63 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents an uca-function in a {@link GroupingExpression}.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class UcaFunction extends FunctionNode {
+
+ private final String locale;
+ private final String strength;
+
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ * @param locale The locale to used for sorting.
+ */
+ public UcaFunction(GroupingExpression exp, String locale) {
+ super("uca", Arrays.asList(exp, new StringValue(locale)));
+ this.locale = locale;
+ this.strength = "TERTIARY";
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ * @param locale The locale to used for sorting.
+ * @param strength The strength level to use.
+ */
+ public UcaFunction(GroupingExpression exp, String locale, String strength) {
+ super("uca", Arrays.asList(exp, new StringValue(locale), new StringValue(strength)));
+ if (!validStrength(strength)) {
+ throw new IllegalArgumentException("Not a valid UCA strength: " + strength);
+ }
+ this.locale = locale;
+ this.strength = strength;
+ }
+
+ private boolean validStrength(String strength) {
+ return (strength.equals("PRIMARY") ||
+ strength.equals("SECONDARY") ||
+ strength.equals("TERTIARY") ||
+ strength.equals("QUATERNARY") ||
+ strength.equals("IDENTICAL"));
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public String getStrength() {
+ return strength;
+ }
+}
+
+
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/XorAggregator.java b/container-search/src/main/java/com/yahoo/search/grouping/request/XorAggregator.java
new file mode 100644
index 00000000000..be0f092b929
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/XorAggregator.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents an xor-aggregator in a {@link GroupingExpression}. It evaluates to the xor of the values that
+ * the contained expression evaluated to over all the inputs.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class XorAggregator extends AggregatorNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to aggregate on.
+ */
+ public XorAggregator(GroupingExpression exp) {
+ super("xor", exp);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/XorBitFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/XorBitFunction.java
new file mode 100644
index 00000000000..304917bf905
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/XorBitFunction.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents an xor-function in a {@link GroupingExpression}. It evaluates to a long that equals the xor of
+ * 'width' bits over the binary representation of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class XorBitFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate.
+ * @param numBits The number of bits of the expression value to xor.
+ */
+ public XorBitFunction(GroupingExpression exp, int numBits) {
+ super("xorbit", Arrays.asList(exp, new LongValue(numBits)));
+ }
+
+ /**
+ * Returns the number of bits of the expression value to xor.
+ *
+ * @return The bit count.
+ */
+ public int getNumBits() {
+ return ((LongValue)getArg(1)).getValue().intValue();
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/XorFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/XorFunction.java
new file mode 100644
index 00000000000..dc47926ea51
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/XorFunction.java
@@ -0,0 +1,43 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.List;
+
+/**
+ * This class represents an xor-function in a {@link GroupingExpression}. It evaluates to a long that equals the result
+ * of and'ing the results of all arguments together in the order they were given to the constructor.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class XorFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param arg1 The first compulsory argument, must evaluate to a long.
+ * @param arg2 The second compulsory argument, must evaluate to a long.
+ * @param argN The optional arguments, must evaluate to a long.
+ */
+ public XorFunction(GroupingExpression arg1, GroupingExpression arg2, GroupingExpression... argN) {
+ this(asList(arg1, arg2, argN));
+ }
+
+ private XorFunction(List<GroupingExpression> args) {
+ super("xor", args);
+ }
+
+ /**
+ * Constructs a new instance of this class from a list of arguments.
+ *
+ * @param args The arguments to pass to the constructor.
+ * @return The created instance.
+ * @throws IllegalArgumentException Thrown if the number of arguments is less than 2.
+ */
+ public static XorFunction newInstance(List<GroupingExpression> args) {
+ if (args.size() < 2) {
+ throw new IllegalArgumentException("Expected 2 or more arguments, got " + args.size() + ".");
+ }
+ return new XorFunction(args);
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/YearFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/YearFunction.java
new file mode 100644
index 00000000000..2115d99140d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/YearFunction.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a year timestamp-function in a {@link GroupingExpression}. It evaluates to a long that equals
+ * the full year (e.g. 2010) of the result of the argument.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class YearFunction extends FunctionNode {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long.
+ */
+ public YearFunction(GroupingExpression exp) {
+ super("time.year", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/YmumValue.java b/container-search/src/main/java/com/yahoo/search/grouping/request/YmumValue.java
new file mode 100644
index 00000000000..5754edd8155
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/YmumValue.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+/**
+ * This class represents a document checksum in a {@link GroupingExpression}. It evaluates to the YMUM checksum of the
+ * input {@link com.yahoo.search.result.Hit}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class YmumValue extends DocumentValue {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public YmumValue() {
+ super("ymum()");
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveXFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveXFunction.java
new file mode 100644
index 00000000000..b4790b912e7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveXFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class ZCurveXFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long or long[].
+ */
+ public ZCurveXFunction(GroupingExpression exp) {
+ super("zcurve.x", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveYFunction.java b/container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveYFunction.java
new file mode 100644
index 00000000000..e9a011f2193
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/ZCurveYFunction.java
@@ -0,0 +1,18 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request;
+
+import java.util.Arrays;
+
+/**
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class ZCurveYFunction extends FunctionNode {
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param exp The expression to evaluate, must evaluate to a long or long[].
+ */
+ public ZCurveYFunction(GroupingExpression exp) {
+ super("zcurve.y", Arrays.asList(exp));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/package-info.java b/container-search/src/main/java/com/yahoo/search/grouping/request/package-info.java
new file mode 100644
index 00000000000..ff30ef2b939
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.grouping.request;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/request/parser/GroupingParserInput.java b/container-search/src/main/java/com/yahoo/search/grouping/request/parser/GroupingParserInput.java
new file mode 100644
index 00000000000..e87291fba18
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/request/parser/GroupingParserInput.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.request.parser;
+
+import com.yahoo.javacc.FastCharStream;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class GroupingParserInput extends FastCharStream implements CharStream {
+
+ public GroupingParserInput(String input) {
+ super(input);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/AbstractList.java b/container-search/src/main/java/com/yahoo/search/grouping/result/AbstractList.java
new file mode 100644
index 00000000000..058c68470c4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/AbstractList.java
@@ -0,0 +1,47 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.collections.LazyMap;
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.result.HitGroup;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class AbstractList extends HitGroup {
+
+ private final Map<String, Continuation> continuations = LazyMap.newHashMap();
+ private final String label;
+
+ /**
+ * <p>Constructs a new instance of this class.</p>
+ *
+ * @param type The type of this list.
+ * @param label The label of this list.
+ */
+ public AbstractList(String type, String label) {
+ super(type + ":" + label);
+ this.label = label;
+ }
+
+ /**
+ * <p>Returns the label of this list.</p>
+ *
+ * @return The label.
+ */
+ public String getLabel() {
+ return label;
+ }
+
+ /**
+ * <p>Returns the map of all possible {@link Continuation}s of this list.</p>
+ *
+ * @return The list of Continuations.
+ */
+ public Map<String, Continuation> continuations() {
+ return continuations;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/BucketGroupId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/BucketGroupId.java
new file mode 100644
index 00000000000..1d6dcc6762c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/BucketGroupId.java
@@ -0,0 +1,60 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import static com.yahoo.text.Lowercase.toLowerCase;
+
+/**
+ * This abstract class is used in {@link Group} instances where the identifying expression evaluated to a {@link
+ * com.yahoo.search.grouping.request.BucketValue}. The range is inclusive-from and exclusive-to.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class BucketGroupId<T> extends GroupId {
+
+ private final T from;
+ private final T to;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param type The type of this id's value.
+ * @param from The inclusive-from of the range.
+ * @param to The exclusive-to of the range.
+ */
+ public BucketGroupId(String type, T from, T to) {
+ this(type, from, String.valueOf(from), to, String.valueOf(to));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param type The type of this id's value.
+ * @param from The inclusive-from of the range.
+ * @param fromImage The String representation of the <tt>from</tt> argument.
+ * @param to The exclusive-to of the range.
+ * @param toImage The String representation of the <tt>to</tt> argument.
+ */
+ public BucketGroupId(String type, T from, String fromImage, T to, String toImage) {
+ super(type, fromImage, toImage);
+ this.from = from;
+ this.to = to;
+ }
+
+ /**
+ * Returns the inclusive-from of the value range.
+ *
+ * @return The from-value.
+ */
+ public T getFrom() {
+ return from;
+ }
+
+ /**
+ * Returns the exclusive-to of the value range.
+ *
+ * @return The to-value.
+ */
+ public T getTo() {
+ return to;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/DoubleBucketId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/DoubleBucketId.java
new file mode 100644
index 00000000000..e9f7ffc04c0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/DoubleBucketId.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link
+ * com.yahoo.search.grouping.request.DoubleBucket}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DoubleBucketId extends BucketGroupId<Double> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The identifying inclusive-from double.
+ * @param to The identifying exclusive-to double.
+ */
+ public DoubleBucketId(Double from, Double to) {
+ super("double_bucket", from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/DoubleId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/DoubleId.java
new file mode 100644
index 00000000000..c6f0b15feb2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/DoubleId.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link Double}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class DoubleId extends ValueGroupId<Double> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The identifying double.
+ */
+ public DoubleId(Double value) {
+ super("double", value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/Group.java b/container-search/src/main/java/com/yahoo/search/grouping/result/Group.java
new file mode 100644
index 00000000000..ddf8fe6140d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/Group.java
@@ -0,0 +1,83 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.result.HitGroup;
+import com.yahoo.search.result.Relevance;
+
+/**
+ * This class represents a single group in the grouping result model. A group may contain any number of results (stored
+ * as fields, use {@link #getField(String)} to access), {@link GroupList} and {@link HitList}. Use the {@link
+ * com.yahoo.search.grouping.GroupingRequest#getResultGroup(com.yahoo.search.Result)} to retrieve an instance of this.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class Group extends HitGroup {
+
+ private static final long serialVersionUID = 2122928012157537800L;
+ private final GroupId groupId;
+
+ /**
+ * Creates a new instance of this class.
+ *
+ * @param groupId The id to assign to this group.
+ * @param rel The relevance of this group.
+ */
+ public Group(GroupId groupId, Relevance rel) {
+ super(groupId.toString(), rel);
+ this.groupId = groupId;
+ }
+
+ /**
+ * Returns the id of this group. This is a model of the otherwise flattened {@link #getId() hit id}.
+ *
+ * @return The group id.
+ */
+ public GroupId getGroupId() {
+ return groupId;
+ }
+
+ /**
+ * Returns the {@link HitList} with the given label. The label is the one given to the {@link
+ * com.yahoo.search.grouping.request.EachOperation} that generated the list. This method returns null if no such
+ * list was found.
+ *
+ * @param label The label of the list to return.
+ * @return The requested list, or null.
+ */
+ public HitList getHitList(String label) {
+ for (Hit hit : this) {
+ if (!(hit instanceof HitList)) {
+ continue;
+ }
+ HitList lst = (HitList)hit;
+ if (!label.equals(lst.getLabel())) {
+ continue;
+ }
+ return lst;
+ }
+ return null;
+ }
+
+ /**
+ * Returns the {@link GroupList} with the given label. The label is the one given to the {@link
+ * com.yahoo.search.grouping.request.EachOperation} that generated the list. This method returns null if no such
+ * list was found.
+ *
+ * @param label The label of the list to return.
+ * @return The requested list, or null.
+ */
+ public GroupList getGroupList(String label) {
+ for (Hit hit : this) {
+ if (!(hit instanceof GroupList)) {
+ continue;
+ }
+ GroupList lst = (GroupList)hit;
+ if (!label.equals(lst.getLabel())) {
+ continue;
+ }
+ return lst;
+ }
+ return null;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/GroupId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/GroupId.java
new file mode 100644
index 00000000000..a9f5102caea
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/GroupId.java
@@ -0,0 +1,44 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This abstract class represents the id of a single group in the grouping result model. A subclass corresponding to the
+ * evaluation result of generating {@link com.yahoo.search.grouping.request.GroupingExpression} is contained in all
+ * {@link Group} objects. It is used by {@link com.yahoo.search.grouping.GroupingRequest} to identify its root result
+ * group, and by all client code for identifying groups.
+ * <p>
+ * The {@link #toString()} method of this class generates a URI-compatible string on the form
+ * "group:&lt;typeName&gt;:&lt;subclassSpecific&gt;".
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class GroupId {
+
+ private final String type;
+ private final String image;
+
+ protected GroupId(String type, Object... args) {
+ this.type = type;
+
+ StringBuilder image = new StringBuilder("group:");
+ image.append(type);
+ for (Object arg : args) {
+ image.append(":").append(arg);
+ }
+ this.image = image.toString();
+ }
+
+ /**
+ * Returns the type name of this group id. This is the second part of the {@link #toString()} value of this.
+ *
+ * @return The type name.
+ */
+ public String getTypeName() {
+ return type;
+ }
+
+ @Override
+ public String toString() {
+ return image;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/GroupList.java b/container-search/src/main/java/com/yahoo/search/grouping/result/GroupList.java
new file mode 100644
index 00000000000..ee8d7c33fa7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/GroupList.java
@@ -0,0 +1,24 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.GroupingRequest;
+
+/**
+ * This class represents a labeled group list in the grouping result model. It is contained in {@link Group}, and
+ * contains one or more {@link Group groups} itself, allowing for a hierarchy of grouping results. Use the {@link
+ * GroupingRequest#getResultGroup(Result)} to retrieve grouping results.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class GroupList extends AbstractList {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param label The label to assign to this.
+ */
+ public GroupList(String label) {
+ super("grouplist", label);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/HitList.java b/container-search/src/main/java/com/yahoo/search/grouping/result/HitList.java
new file mode 100644
index 00000000000..abc87a92ab1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/HitList.java
@@ -0,0 +1,25 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.GroupingRequest;
+import com.yahoo.search.result.Hit;
+
+/**
+ * <p>This class represents a labeled hit list in the grouping result model. It is contained in {@link Group}, and
+ * contains one or more {@link Hit hits} itself, making this the parent of leaf nodes in the hierarchy of grouping
+ * results. Use the {@link GroupingRequest#getResultGroup(Result)} to retrieve grouping results.</p>
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class HitList extends AbstractList {
+
+ /**
+ * <p>Constructs a new instance of this class.</p>
+ *
+ * @param label The label to assign to this.
+ */
+ public HitList(String label) {
+ super("hitlist", label);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/HitRenderer.java b/container-search/src/main/java/com/yahoo/search/grouping/result/HitRenderer.java
new file mode 100644
index 00000000000..7558af5acb5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/HitRenderer.java
@@ -0,0 +1,99 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.result.HitGroup;
+import com.yahoo.text.Utf8String;
+import com.yahoo.text.XMLWriter;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+
+/**
+ * This is a helper class for rendering grouping results.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class HitRenderer {
+
+ private static final Utf8String ATR_LABEL = new Utf8String("label");
+ private static final Utf8String ATR_RELEVANCE = new Utf8String("relevance");
+ private static final Utf8String ATR_TYPE = new Utf8String("type");
+ private static final Utf8String TAG_BUCKET_FROM = new Utf8String("from");
+ private static final Utf8String TAG_BUCKET_TO = new Utf8String("to");
+ private static final Utf8String TAG_CONTINUATION = new Utf8String("continuation");
+ private static final Utf8String TAG_CONTINUATION_ID = new Utf8String("id");
+ private static final Utf8String TAG_GROUP_LIST = new Utf8String("grouplist");
+ private static final Utf8String TAG_GROUP = new Utf8String("group");
+ private static final Utf8String TAG_GROUP_ID = new Utf8String("id");
+ private static final Utf8String TAG_HIT_LIST = new Utf8String("hitlist");
+ private static final Utf8String TAG_OUTPUT = new Utf8String("output");
+
+ /**
+ * Renders the header for the given grouping hit. If the hit is not a grouping hit, this method does nothing and
+ * returns false.
+ * <p>Post-condition if this is a grouping hit: The hit tag is open.
+ *
+ * @param hit The hit whose header to render.
+ * @param writer The writer to render to.
+ * @return True if the hit was rendered.
+ * @throws IOException Thrown if there was a problem writing.
+ */
+ public static boolean renderHeader(HitGroup hit, XMLWriter writer) throws IOException {
+ if (hit instanceof GroupList) {
+ writer.openTag(TAG_GROUP_LIST).attribute(ATR_LABEL, ((GroupList)hit).getLabel());
+ renderContinuations(((GroupList)hit).continuations(), writer);
+ } else if (hit instanceof Group) {
+ writer.openTag(TAG_GROUP).attribute(ATR_RELEVANCE, hit.getRelevance().toString());
+ renderGroupId(((Group)hit).getGroupId(), writer);
+ if (hit instanceof RootGroup) {
+ renderContinuation(Continuation.THIS_PAGE, ((RootGroup)hit).continuation(), writer);
+ }
+ for (String label : hit.fieldKeys()) {
+ writer.openTag(TAG_OUTPUT).attribute(ATR_LABEL, label).content(hit.getField(label), false).closeTag();
+ }
+ } else if (hit instanceof HitList) {
+ writer.openTag(TAG_HIT_LIST).attribute(ATR_LABEL, ((HitList)hit).getLabel());
+ renderContinuations(((HitList)hit).continuations(), writer);
+ } else {
+ return false;
+ }
+ writer.closeStartTag();
+ return true;
+ }
+
+ private static void renderGroupId(GroupId id, XMLWriter writer) {
+ writer.openTag(TAG_GROUP_ID).attribute(ATR_TYPE, id.getTypeName());
+ if (id instanceof ValueGroupId) {
+ writer.content(getIdValue((ValueGroupId)id), false);
+ } else if (id instanceof BucketGroupId) {
+ BucketGroupId bucketId = (BucketGroupId)id;
+ writer.openTag(TAG_BUCKET_FROM).content(getBucketFrom(bucketId), false).closeTag();
+ writer.openTag(TAG_BUCKET_TO).content(getBucketTo(bucketId), false).closeTag();
+ }
+ writer.closeTag();
+ }
+
+ private static Object getIdValue(ValueGroupId id) {
+ return id instanceof RawId ? Arrays.toString(((RawId)id).getValue()) : id.getValue();
+ }
+
+ private static Object getBucketFrom(BucketGroupId id) {
+ return id instanceof RawBucketId ? Arrays.toString(((RawBucketId)id).getFrom()) : id.getFrom();
+ }
+
+ private static Object getBucketTo(BucketGroupId id) {
+ return id instanceof RawBucketId ? Arrays.toString(((RawBucketId)id).getTo()) : id.getTo();
+ }
+
+ private static void renderContinuations(Map<String, Continuation> continuations, XMLWriter writer) {
+ for (Map.Entry<String, Continuation> entry : continuations.entrySet()) {
+ renderContinuation(entry.getKey(), entry.getValue(), writer);
+ }
+ }
+
+ private static void renderContinuation(String id, Continuation continuation, XMLWriter writer) {
+ writer.openTag(TAG_CONTINUATION).attribute(TAG_CONTINUATION_ID, id).content(continuation, false).closeTag();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/LongBucketId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/LongBucketId.java
new file mode 100644
index 00000000000..14ced353b67
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/LongBucketId.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link
+ * com.yahoo.search.grouping.request.LongBucket}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class LongBucketId extends BucketGroupId<Long> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The identifying inclusive-from long.
+ * @param to The identifying exclusive-to long.
+ */
+ public LongBucketId(Long from, Long to) {
+ super("long_bucket", from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/LongId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/LongId.java
new file mode 100644
index 00000000000..18d2098a5a1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/LongId.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link Long}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class LongId extends ValueGroupId<Long> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The identifying long.
+ */
+ public LongId(Long value) {
+ super("long", value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/NullId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/NullId.java
new file mode 100644
index 00000000000..a6473837c76
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/NullId.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is in {@link Group} instances where the identifying expression evaluated to null. For example, hits that
+ * fall outside the buckets of a {@link com.yahoo.search.grouping.request.PredefinedFunction} are added to an
+ * auto-generated group with this id.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class NullId extends GroupId {
+
+ /**
+ * Constructs a new instance of this class.
+ */
+ public NullId() {
+ super("null");
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/RawBucketId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/RawBucketId.java
new file mode 100644
index 00000000000..bb0dae9d6b8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/RawBucketId.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import java.util.Arrays;
+
+/**
+ * This class is used in {@link Group} instances where the identifying
+ * expression evaluated to a {@link com.yahoo.search.grouping.request.RawBucket}.
+ *
+ * @author <a href="mailto:lulf@yahoo-inc.com">Ulf Lilleengen</a>
+ */
+public class RawBucketId extends BucketGroupId<byte[]> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The identifying inclusive-from raw buffer.
+ * @param to The identifying exclusive-to raw buffer.
+ */
+ public RawBucketId(byte[] from, byte[] to) {
+ super("raw_bucket", from, Arrays.toString(from), to, Arrays.toString(to));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/RawId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/RawId.java
new file mode 100644
index 00000000000..48e9c6e4523
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/RawId.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import java.util.Arrays;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link Byte} array.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class RawId extends ValueGroupId<byte[]> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The identifying byte array.
+ */
+ public RawId(byte[] value) {
+ super("raw", value, Arrays.toString(value));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/RootGroup.java b/container-search/src/main/java/com/yahoo/search/grouping/result/RootGroup.java
new file mode 100644
index 00000000000..238f9ec68f3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/RootGroup.java
@@ -0,0 +1,25 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.result.Relevance;
+
+/**
+ * This class represents the root {@link Group} in the grouping result model. This class adds a {@link Continuation}
+ * object that can be used to paginate the result.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class RootGroup extends Group {
+
+ private final Continuation continuation;
+
+ public RootGroup(int id, Continuation continuation) {
+ super(new RootId(id), new Relevance(1.0));
+ this.continuation = continuation;
+ }
+
+ public Continuation continuation() {
+ return continuation;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/RootId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/RootId.java
new file mode 100644
index 00000000000..ebf3152646a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/RootId.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link RootGroup} instances.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class RootId extends GroupId {
+
+ public RootId(int id) {
+ super("root", id);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/StringBucketId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/StringBucketId.java
new file mode 100644
index 00000000000..0b4459aa4b6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/StringBucketId.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link
+ * com.yahoo.search.grouping.request.StringBucket}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StringBucketId extends BucketGroupId<String> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param from The identifying inclusive-from string.
+ * @param to The identifying exclusive-to string.
+ */
+ public StringBucketId(String from, String to) {
+ super("string_bucket", from, to);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/StringId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/StringId.java
new file mode 100644
index 00000000000..0a82b98af44
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/StringId.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+/**
+ * This class is used in {@link Group} instances where the identifying expression evaluated to a {@link String}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class StringId extends ValueGroupId<String> {
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param value The identifying string.
+ */
+ public StringId(String value) {
+ super("string", value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/ValueGroupId.java b/container-search/src/main/java/com/yahoo/search/grouping/result/ValueGroupId.java
new file mode 100644
index 00000000000..f6e815b231c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/ValueGroupId.java
@@ -0,0 +1,45 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.result;
+
+import static com.yahoo.text.Lowercase.toLowerCase;
+
+/**
+ * This abstract class is used in {@link Group} instances where the identifying expression evaluated to a singe value.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public abstract class ValueGroupId<T> extends GroupId {
+
+ private final T value;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param type The type of this id's value.
+ * @param value The identifying value.
+ */
+ public ValueGroupId(String type, T value) {
+ this(type, value, String.valueOf(value.toString()));
+ }
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param type The type of this id's value.
+ * @param value The identifying value.
+ * @param valueImage The String representation of the <tt>value</tt> argument.
+ */
+ public ValueGroupId(String type, T value, String valueImage) {
+ super(type, valueImage);
+ this.value = value;
+ }
+
+ /**
+ * Returns the identifying value.
+ *
+ * @return The value.
+ */
+ public T getValue() {
+ return value;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/result/package-info.java b/container-search/src/main/java/com/yahoo/search/grouping/result/package-info.java
new file mode 100644
index 00000000000..6c70f67971d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/result/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.grouping.result;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/CompositeContinuation.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/CompositeContinuation.java
new file mode 100644
index 00000000000..e8efce2d0bc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/CompositeContinuation.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.Continuation;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class CompositeContinuation extends EncodableContinuation implements Iterable<EncodableContinuation> {
+
+ private final List<EncodableContinuation> children = new ArrayList<>();
+
+ public CompositeContinuation add(EncodableContinuation child) {
+ children.add(child);
+ return this;
+ }
+
+ @Override
+ public Iterator<EncodableContinuation> iterator() {
+ return children.iterator();
+ }
+
+ @Override
+ public int hashCode() {
+ return children.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof CompositeContinuation && children.equals(((CompositeContinuation)obj).children);
+ }
+
+ @Override
+ public void encode(IntegerEncoder out) {
+ for (EncodableContinuation child : children) {
+ child.encode(out);
+ }
+ }
+
+ public static CompositeContinuation decode(IntegerDecoder from) {
+ CompositeContinuation ret = new CompositeContinuation();
+ while (from.hasNext()) {
+ ret.add(OffsetContinuation.decode(from));
+ }
+ return ret;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/ContinuationDecoder.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ContinuationDecoder.java
new file mode 100644
index 00000000000..a8779be09c2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ContinuationDecoder.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.Continuation;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public class ContinuationDecoder {
+
+ public static Continuation decode(String str) {
+ return CompositeContinuation.decode(new IntegerDecoder(str));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/EncodableContinuation.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/EncodableContinuation.java
new file mode 100644
index 00000000000..ca059cbe1fe
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/EncodableContinuation.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.Continuation;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+abstract class EncodableContinuation extends Continuation {
+
+ public abstract void encode(IntegerEncoder out);
+
+ @Override
+ public final String toString() {
+ IntegerEncoder encoder = new IntegerEncoder();
+ encode(encoder);
+ return encoder.toString();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/ExpressionConverter.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ExpressionConverter.java
new file mode 100644
index 00000000000..9de1c902be1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ExpressionConverter.java
@@ -0,0 +1,598 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.request.AddFunction;
+import com.yahoo.search.grouping.request.AggregatorNode;
+import com.yahoo.search.grouping.request.AndFunction;
+import com.yahoo.search.grouping.request.ArrayAtLookup;
+import com.yahoo.search.grouping.request.AttributeFunction;
+import com.yahoo.search.grouping.request.AttributeValue;
+import com.yahoo.search.grouping.request.AvgAggregator;
+import com.yahoo.search.grouping.request.BucketValue;
+import com.yahoo.search.grouping.request.CatFunction;
+import com.yahoo.search.grouping.request.ConstantValue;
+import com.yahoo.search.grouping.request.CountAggregator;
+import com.yahoo.search.grouping.request.DateFunction;
+import com.yahoo.search.grouping.request.DayOfMonthFunction;
+import com.yahoo.search.grouping.request.DayOfWeekFunction;
+import com.yahoo.search.grouping.request.DayOfYearFunction;
+import com.yahoo.search.grouping.request.DebugWaitFunction;
+import com.yahoo.search.grouping.request.DivFunction;
+import com.yahoo.search.grouping.request.DocIdNsSpecificValue;
+import com.yahoo.search.grouping.request.DoubleValue;
+import com.yahoo.search.grouping.request.FixedWidthFunction;
+import com.yahoo.search.grouping.request.GroupingExpression;
+import com.yahoo.search.grouping.request.GroupingOperation;
+import com.yahoo.search.grouping.request.HourOfDayFunction;
+import com.yahoo.search.grouping.request.InfiniteValue;
+import com.yahoo.search.grouping.request.InterpolatedLookup;
+import com.yahoo.search.grouping.request.LongValue;
+import com.yahoo.search.grouping.request.MathACosFunction;
+import com.yahoo.search.grouping.request.MathACosHFunction;
+import com.yahoo.search.grouping.request.MathASinFunction;
+import com.yahoo.search.grouping.request.MathASinHFunction;
+import com.yahoo.search.grouping.request.MathATanFunction;
+import com.yahoo.search.grouping.request.MathATanHFunction;
+import com.yahoo.search.grouping.request.MathCbrtFunction;
+import com.yahoo.search.grouping.request.MathCosFunction;
+import com.yahoo.search.grouping.request.MathCosHFunction;
+import com.yahoo.search.grouping.request.MathExpFunction;
+import com.yahoo.search.grouping.request.MathFloorFunction;
+import com.yahoo.search.grouping.request.MathHypotFunction;
+import com.yahoo.search.grouping.request.MathLog10Function;
+import com.yahoo.search.grouping.request.MathLog1pFunction;
+import com.yahoo.search.grouping.request.MathLogFunction;
+import com.yahoo.search.grouping.request.MathPowFunction;
+import com.yahoo.search.grouping.request.MathSinFunction;
+import com.yahoo.search.grouping.request.MathSinHFunction;
+import com.yahoo.search.grouping.request.MathSqrtFunction;
+import com.yahoo.search.grouping.request.MathTanFunction;
+import com.yahoo.search.grouping.request.MathTanHFunction;
+import com.yahoo.search.grouping.request.MaxAggregator;
+import com.yahoo.search.grouping.request.MaxFunction;
+import com.yahoo.search.grouping.request.Md5Function;
+import com.yahoo.search.grouping.request.MinAggregator;
+import com.yahoo.search.grouping.request.MinFunction;
+import com.yahoo.search.grouping.request.MinuteOfHourFunction;
+import com.yahoo.search.grouping.request.ModFunction;
+import com.yahoo.search.grouping.request.MonthOfYearFunction;
+import com.yahoo.search.grouping.request.MulFunction;
+import com.yahoo.search.grouping.request.NegFunction;
+import com.yahoo.search.grouping.request.NormalizeSubjectFunction;
+import com.yahoo.search.grouping.request.NowFunction;
+import com.yahoo.search.grouping.request.OrFunction;
+import com.yahoo.search.grouping.request.PredefinedFunction;
+import com.yahoo.search.grouping.request.RawValue;
+import com.yahoo.search.grouping.request.RelevanceValue;
+import com.yahoo.search.grouping.request.ReverseFunction;
+import com.yahoo.search.grouping.request.SecondOfMinuteFunction;
+import com.yahoo.search.grouping.request.SizeFunction;
+import com.yahoo.search.grouping.request.SortFunction;
+import com.yahoo.search.grouping.request.StrCatFunction;
+import com.yahoo.search.grouping.request.StrLenFunction;
+import com.yahoo.search.grouping.request.StringValue;
+import com.yahoo.search.grouping.request.SubFunction;
+import com.yahoo.search.grouping.request.SumAggregator;
+import com.yahoo.search.grouping.request.SummaryValue;
+import com.yahoo.search.grouping.request.ToDoubleFunction;
+import com.yahoo.search.grouping.request.ToLongFunction;
+import com.yahoo.search.grouping.request.ToRawFunction;
+import com.yahoo.search.grouping.request.ToStringFunction;
+import com.yahoo.search.grouping.request.UcaFunction;
+import com.yahoo.search.grouping.request.XorAggregator;
+import com.yahoo.search.grouping.request.XorBitFunction;
+import com.yahoo.search.grouping.request.XorFunction;
+import com.yahoo.search.grouping.request.YearFunction;
+import com.yahoo.search.grouping.request.YmumValue;
+import com.yahoo.search.grouping.request.ZCurveXFunction;
+import com.yahoo.search.grouping.request.ZCurveYFunction;
+
+import com.yahoo.searchlib.aggregation.AggregationResult;
+import com.yahoo.searchlib.aggregation.AverageAggregationResult;
+import com.yahoo.searchlib.aggregation.CountAggregationResult;
+import com.yahoo.searchlib.aggregation.ExpressionCountAggregationResult;
+import com.yahoo.searchlib.aggregation.HitsAggregationResult;
+import com.yahoo.searchlib.aggregation.MaxAggregationResult;
+import com.yahoo.searchlib.aggregation.MinAggregationResult;
+import com.yahoo.searchlib.aggregation.SumAggregationResult;
+import com.yahoo.searchlib.aggregation.XorAggregationResult;
+
+import com.yahoo.searchlib.expression.AddFunctionNode;
+import com.yahoo.searchlib.expression.AggregationRefNode;
+import com.yahoo.searchlib.expression.AndFunctionNode;
+import com.yahoo.searchlib.expression.ArrayAtLookupNode;
+import com.yahoo.searchlib.expression.AttributeNode;
+import com.yahoo.searchlib.expression.BucketResultNode;
+import com.yahoo.searchlib.expression.CatFunctionNode;
+import com.yahoo.searchlib.expression.ConstantNode;
+import com.yahoo.searchlib.expression.DebugWaitFunctionNode;
+import com.yahoo.searchlib.expression.DivideFunctionNode;
+import com.yahoo.searchlib.expression.ExpressionNode;
+import com.yahoo.searchlib.expression.FixedWidthBucketFunctionNode;
+import com.yahoo.searchlib.expression.FloatBucketResultNode;
+import com.yahoo.searchlib.expression.FloatBucketResultNodeVector;
+import com.yahoo.searchlib.expression.FloatResultNode;
+import com.yahoo.searchlib.expression.GetDocIdNamespaceSpecificFunctionNode;
+import com.yahoo.searchlib.expression.GetYMUMChecksumFunctionNode;
+import com.yahoo.searchlib.expression.IntegerBucketResultNode;
+import com.yahoo.searchlib.expression.IntegerBucketResultNodeVector;
+import com.yahoo.searchlib.expression.IntegerResultNode;
+import com.yahoo.searchlib.expression.InterpolatedLookupNode;
+import com.yahoo.searchlib.expression.MD5BitFunctionNode;
+import com.yahoo.searchlib.expression.MathFunctionNode;
+import com.yahoo.searchlib.expression.MaxFunctionNode;
+import com.yahoo.searchlib.expression.MinFunctionNode;
+import com.yahoo.searchlib.expression.ModuloFunctionNode;
+import com.yahoo.searchlib.expression.MultiArgFunctionNode;
+import com.yahoo.searchlib.expression.MultiplyFunctionNode;
+import com.yahoo.searchlib.expression.NegateFunctionNode;
+import com.yahoo.searchlib.expression.NormalizeSubjectFunctionNode;
+import com.yahoo.searchlib.expression.NumElemFunctionNode;
+import com.yahoo.searchlib.expression.OrFunctionNode;
+import com.yahoo.searchlib.expression.RangeBucketPreDefFunctionNode;
+import com.yahoo.searchlib.expression.RawBucketResultNode;
+import com.yahoo.searchlib.expression.RawBucketResultNodeVector;
+import com.yahoo.searchlib.expression.RawResultNode;
+import com.yahoo.searchlib.expression.RelevanceNode;
+import com.yahoo.searchlib.expression.ResultNodeVector;
+import com.yahoo.searchlib.expression.ReverseFunctionNode;
+import com.yahoo.searchlib.expression.SortFunctionNode;
+import com.yahoo.searchlib.expression.StrCatFunctionNode;
+import com.yahoo.searchlib.expression.StrLenFunctionNode;
+import com.yahoo.searchlib.expression.StringBucketResultNode;
+import com.yahoo.searchlib.expression.StringBucketResultNodeVector;
+import com.yahoo.searchlib.expression.StringResultNode;
+import com.yahoo.searchlib.expression.TimeStampFunctionNode;
+import com.yahoo.searchlib.expression.ToFloatFunctionNode;
+import com.yahoo.searchlib.expression.ToIntFunctionNode;
+import com.yahoo.searchlib.expression.ToRawFunctionNode;
+import com.yahoo.searchlib.expression.ToStringFunctionNode;
+import com.yahoo.searchlib.expression.UcaFunctionNode;
+import com.yahoo.searchlib.expression.XorBitFunctionNode;
+import com.yahoo.searchlib.expression.XorFunctionNode;
+import com.yahoo.searchlib.expression.ZCurveFunctionNode;
+
+/**
+ * This is a helper class for {@link RequestBuilder} that offloads the code to convert {@link GroupingExpression} type
+ * objects to back-end specific expressions. This is a straightforward one-to-one conversion.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class ExpressionConverter {
+
+ public static final String DEFAULT_SUMMARY_NAME = "";
+ public static final int DEFAULT_TIME_OFFSET = 0;
+ private String defaultSummaryName = DEFAULT_SUMMARY_NAME;
+ private int timeOffset = DEFAULT_TIME_OFFSET;
+
+ /**
+ * Sets the summary name to use when converting {@link SummaryValue} that was created without an explicit name.
+ *
+ * @param summaryName The default summary name to use.
+ * @return This, to allow chaining.
+ */
+ public ExpressionConverter setDefaultSummaryName(String summaryName) {
+ defaultSummaryName = summaryName;
+ return this;
+ }
+
+ /**
+ * Sets an offset to use for all time-based grouping expressions.
+ *
+ * @param millis The offset in milliseconds.
+ * @return This, to allow chaining.
+ */
+ public ExpressionConverter setTimeOffset(int millis) {
+ this.timeOffset = millis / 1000;
+ return this;
+ }
+
+ /**
+ * Converts the given ast type grouping expression to a corresponding back-end type aggregation result.
+ *
+ * @param exp The expression to convert.
+ * @return The corresponding back-end result.
+ * @throws UnsupportedOperationException Thrown if the given expression could not be converted.
+ */
+ public AggregationResult toAggregationResult(GroupingExpression exp) {
+ int level = exp.getLevel();
+ // Is aggregating on list of groups?
+ if (level > 1) {
+ /*
+ * The below aggregator operates on lists of groups in the query language world.
+ * Internally, it operates on hits (by evaluating the group-by expression for each hit).
+ * The group-by expression is passed to the aggregator by RequestBuilder.
+ */
+ if (exp instanceof CountAggregator) {
+ return new ExpressionCountAggregationResult();
+ }
+ throw new UnsupportedOperationException(
+ "Can not aggregate on " + GroupingOperation.getLevelDesc(level) + ".");
+ }
+ if (exp instanceof AvgAggregator) {
+ return new AverageAggregationResult()
+ .setExpression(toExpressionNode(((AvgAggregator)exp).getExpression()));
+ }
+ if (exp instanceof CountAggregator) {
+ return new CountAggregationResult()
+ .setExpression(new ConstantNode(new IntegerResultNode(0)));
+ }
+ if (exp instanceof MaxAggregator) {
+ return new MaxAggregationResult()
+ .setExpression(toExpressionNode(((MaxAggregator)exp).getExpression()));
+ }
+ if (exp instanceof MinAggregator) {
+ return new MinAggregationResult()
+ .setExpression(toExpressionNode(((MinAggregator)exp).getExpression()));
+ }
+ if (exp instanceof SumAggregator) {
+ return new SumAggregationResult()
+ .setExpression(toExpressionNode(((SumAggregator)exp).getExpression()));
+ }
+ if (exp instanceof SummaryValue) {
+ String summaryName = ((SummaryValue)exp).getSummaryName();
+ return new HitsAggregationResult()
+ .setSummaryClass(summaryName != null ? summaryName : defaultSummaryName)
+ .setExpression(new ConstantNode(new IntegerResultNode(0)));
+ }
+ if (exp instanceof XorAggregator) {
+ return new XorAggregationResult()
+ .setExpression(toExpressionNode(((XorAggregator)exp).getExpression()));
+ }
+ throw new UnsupportedOperationException("Can not convert '" + exp + "' to an aggregator.");
+ }
+
+ /**
+ * Converts the given ast type grouping expression to a corresponding back-end type expression.
+ *
+ * @param exp The expression to convert.
+ * @return The corresponding back-end expression.
+ * @throws UnsupportedOperationException Thrown if the given expression could not be converted.
+ */
+ public ExpressionNode toExpressionNode(GroupingExpression exp) {
+ if (exp instanceof AddFunction) {
+ return addArguments(new AddFunctionNode(), (AddFunction)exp);
+ }
+ if (exp instanceof AggregatorNode) {
+ return new AggregationRefNode(toAggregationResult(exp));
+ }
+ if (exp instanceof AndFunction) {
+ return addArguments(new AndFunctionNode(), (AndFunction)exp);
+ }
+ if (exp instanceof AttributeValue) {
+ return new AttributeNode(((AttributeValue)exp).getAttributeName());
+ }
+ if (exp instanceof AttributeFunction) {
+ return new AttributeNode(((AttributeFunction)exp).getAttributeName());
+ }
+ if (exp instanceof CatFunction) {
+ return addArguments(new CatFunctionNode(), (CatFunction)exp);
+ }
+ if (exp instanceof DebugWaitFunction) {
+ return new DebugWaitFunctionNode(toExpressionNode(((DebugWaitFunction)exp).getArg(0)),
+ ((DebugWaitFunction)exp).getWaitTime(),
+ ((DebugWaitFunction)exp).getBusyWait());
+ }
+ if (exp instanceof DocIdNsSpecificValue) {
+ return new GetDocIdNamespaceSpecificFunctionNode();
+ }
+ if (exp instanceof DoubleValue) {
+ return new ConstantNode(new FloatResultNode(((DoubleValue)exp).getValue()));
+ }
+ if (exp instanceof DivFunction) {
+ return addArguments(new DivideFunctionNode(), (DivFunction)exp);
+ }
+ if (exp instanceof FixedWidthFunction) {
+ Number w = ((FixedWidthFunction)exp).getWidth();
+ return new FixedWidthBucketFunctionNode(
+ w instanceof Double ? new FloatResultNode(w.doubleValue()) : new IntegerResultNode(w.longValue()),
+ toExpressionNode(((FixedWidthFunction)exp).getArg(0)));
+ }
+ if (exp instanceof LongValue) {
+ return new ConstantNode(new IntegerResultNode(((LongValue)exp).getValue()));
+ }
+ if (exp instanceof MaxFunction) {
+ return addArguments(new MaxFunctionNode(), (MaxFunction)exp);
+ }
+ if (exp instanceof Md5Function) {
+ return new MD5BitFunctionNode().setNumBits(((Md5Function)exp).getNumBits())
+ .addArg(toExpressionNode(((Md5Function)exp).getArg(0)));
+ }
+ if (exp instanceof UcaFunction) {
+ UcaFunction uca = (UcaFunction)exp;
+ return new UcaFunctionNode(toExpressionNode(uca.getArg(0)), uca.getLocale(), uca.getStrength());
+ }
+ if (exp instanceof MinFunction) {
+ return addArguments(new MinFunctionNode(), (MinFunction)exp);
+ }
+ if (exp instanceof ModFunction) {
+ return addArguments(new ModuloFunctionNode(), (ModFunction)exp);
+ }
+ if (exp instanceof MulFunction) {
+ return addArguments(new MultiplyFunctionNode(), (MulFunction)exp);
+ }
+ if (exp instanceof NegFunction) {
+ return new NegateFunctionNode(toExpressionNode(((NegFunction)exp).getArg(0)));
+ }
+ if (exp instanceof NormalizeSubjectFunction) {
+ return new NormalizeSubjectFunctionNode(toExpressionNode(((NormalizeSubjectFunction)exp).getArg(0)));
+ }
+ if (exp instanceof NowFunction) {
+ return new ConstantNode(new IntegerResultNode(System.currentTimeMillis() / 1000));
+ }
+ if (exp instanceof OrFunction) {
+ return addArguments(new OrFunctionNode(), (OrFunction)exp);
+ }
+ if (exp instanceof PredefinedFunction) {
+ return new RangeBucketPreDefFunctionNode(toBucketList((PredefinedFunction)exp),
+ toExpressionNode(((PredefinedFunction)exp).getArg(0)));
+ }
+ if (exp instanceof RelevanceValue) {
+ return new RelevanceNode();
+ }
+ if (exp instanceof ReverseFunction) {
+ return new ReverseFunctionNode(toExpressionNode(((ReverseFunction)exp).getArg(0)));
+ }
+ if (exp instanceof SizeFunction) {
+ return new NumElemFunctionNode(toExpressionNode(((SizeFunction)exp).getArg(0)));
+ }
+ if (exp instanceof SortFunction) {
+ return new SortFunctionNode(toExpressionNode(((SortFunction)exp).getArg(0)));
+ }
+ if (exp instanceof ArrayAtLookup) {
+ ArrayAtLookup aal = (ArrayAtLookup) exp;
+ return new ArrayAtLookupNode(aal.getAttributeName(), toExpressionNode(aal.getIndexArgument()));
+ }
+ if (exp instanceof InterpolatedLookup) {
+ InterpolatedLookup sarl = (InterpolatedLookup) exp;
+ return new InterpolatedLookupNode(sarl.getAttributeName(), toExpressionNode(sarl.getLookupArgument()));
+ }
+ if (exp instanceof StrCatFunction) {
+ return addArguments(new StrCatFunctionNode(), (StrCatFunction)exp);
+ }
+ if (exp instanceof StringValue) {
+ return new ConstantNode(new StringResultNode(((StringValue)exp).getValue()));
+ }
+ if (exp instanceof StrLenFunction) {
+ return new StrLenFunctionNode(toExpressionNode(((StrLenFunction)exp).getArg(0)));
+ }
+ if (exp instanceof SubFunction) {
+ return toSubNode((SubFunction)exp);
+ }
+ if (exp instanceof ToDoubleFunction) {
+ return new ToFloatFunctionNode(toExpressionNode(((ToDoubleFunction)exp).getArg(0)));
+ }
+ if (exp instanceof ToLongFunction) {
+ return new ToIntFunctionNode(toExpressionNode(((ToLongFunction)exp).getArg(0)));
+ }
+ if (exp instanceof ToRawFunction) {
+ return new ToRawFunctionNode(toExpressionNode(((ToRawFunction)exp).getArg(0)));
+ }
+ if (exp instanceof ToStringFunction) {
+ return new ToStringFunctionNode(toExpressionNode(((ToStringFunction)exp).getArg(0)));
+ }
+ if (exp instanceof DateFunction) {
+ StrCatFunctionNode ret = new StrCatFunctionNode();
+ GroupingExpression arg = ((DateFunction)exp).getArg(0);
+ ret.addArg(new ToStringFunctionNode(toTime(arg, TimeStampFunctionNode.TimePart.Year)));
+ ret.addArg(new ConstantNode(new StringResultNode("-")));
+ ret.addArg(new ToStringFunctionNode(toTime(arg, TimeStampFunctionNode.TimePart.Month)));
+ ret.addArg(new ConstantNode(new StringResultNode("-")));
+ ret.addArg(new ToStringFunctionNode(toTime(arg, TimeStampFunctionNode.TimePart.MonthDay)));
+ return ret;
+ }
+ if (exp instanceof MathSqrtFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathSqrtFunction)exp).getArg(0)),
+ MathFunctionNode.Function.SQRT);
+ }
+ if (exp instanceof MathCbrtFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathCbrtFunction)exp).getArg(0)),
+ MathFunctionNode.Function.CBRT);
+ }
+ if (exp instanceof MathLogFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathLogFunction)exp).getArg(0)),
+ MathFunctionNode.Function.LOG);
+ }
+ if (exp instanceof MathLog1pFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathLog1pFunction)exp).getArg(0)),
+ MathFunctionNode.Function.LOG1P);
+ }
+ if (exp instanceof MathLog10Function) {
+ return new MathFunctionNode(toExpressionNode(((MathLog10Function)exp).getArg(0)),
+ MathFunctionNode.Function.LOG10);
+ }
+ if (exp instanceof MathExpFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathExpFunction)exp).getArg(0)),
+ MathFunctionNode.Function.EXP);
+ }
+ if (exp instanceof MathPowFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathPowFunction)exp).getArg(0)),
+ MathFunctionNode.Function.POW)
+ .addArg(toExpressionNode(((MathPowFunction)exp).getArg(1)));
+ }
+ if (exp instanceof MathHypotFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathHypotFunction)exp).getArg(0)),
+ MathFunctionNode.Function.HYPOT)
+ .addArg(toExpressionNode(((MathHypotFunction)exp).getArg(1)));
+ }
+ if (exp instanceof MathSinFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathSinFunction)exp).getArg(0)),
+ MathFunctionNode.Function.SIN);
+ }
+ if (exp instanceof MathASinFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathASinFunction)exp).getArg(0)),
+ MathFunctionNode.Function.ASIN);
+ }
+ if (exp instanceof MathCosFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathCosFunction)exp).getArg(0)),
+ MathFunctionNode.Function.COS);
+ }
+ if (exp instanceof MathACosFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathACosFunction)exp).getArg(0)),
+ MathFunctionNode.Function.ACOS);
+ }
+ if (exp instanceof MathTanFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathTanFunction)exp).getArg(0)),
+ MathFunctionNode.Function.TAN);
+ }
+ if (exp instanceof MathATanFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathATanFunction)exp).getArg(0)),
+ MathFunctionNode.Function.ATAN);
+ }
+ if (exp instanceof MathSinHFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathSinHFunction)exp).getArg(0)),
+ MathFunctionNode.Function.SINH);
+ }
+ if (exp instanceof MathASinHFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathASinHFunction)exp).getArg(0)),
+ MathFunctionNode.Function.ASINH);
+ }
+ if (exp instanceof MathCosHFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathCosHFunction)exp).getArg(0)),
+ MathFunctionNode.Function.COSH);
+ }
+ if (exp instanceof MathACosHFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathACosHFunction)exp).getArg(0)),
+ MathFunctionNode.Function.ACOSH);
+ }
+ if (exp instanceof MathTanHFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathTanHFunction)exp).getArg(0)),
+ MathFunctionNode.Function.TANH);
+ }
+ if (exp instanceof MathATanHFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathATanHFunction)exp).getArg(0)),
+ MathFunctionNode.Function.ATANH);
+ }
+ if (exp instanceof MathFloorFunction) {
+ return new MathFunctionNode(toExpressionNode(((MathFloorFunction)exp).getArg(0)),
+ MathFunctionNode.Function.FLOOR);
+ }
+ if (exp instanceof ZCurveXFunction) {
+ return new ZCurveFunctionNode(toExpressionNode(((ZCurveXFunction)exp).getArg(0)),
+ ZCurveFunctionNode.Dimension.X);
+ }
+ if (exp instanceof ZCurveYFunction) {
+ return new ZCurveFunctionNode(toExpressionNode(((ZCurveYFunction)exp).getArg(0)),
+ ZCurveFunctionNode.Dimension.Y);
+ }
+ if (exp instanceof DayOfMonthFunction) {
+ return toTime(((DayOfMonthFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.MonthDay);
+ }
+ if (exp instanceof DayOfWeekFunction) {
+ return toTime(((DayOfWeekFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.WeekDay);
+ }
+ if (exp instanceof DayOfYearFunction) {
+ return toTime(((DayOfYearFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.YearDay);
+ }
+ if (exp instanceof HourOfDayFunction) {
+ return toTime(((HourOfDayFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.Hour);
+ }
+ if (exp instanceof MinuteOfHourFunction) {
+ return toTime(((MinuteOfHourFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.Minute);
+ }
+ if (exp instanceof MonthOfYearFunction) {
+ return toTime(((MonthOfYearFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.Month);
+ }
+ if (exp instanceof SecondOfMinuteFunction) {
+ return toTime(((SecondOfMinuteFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.Second);
+ }
+ if (exp instanceof YearFunction) {
+ return toTime(((YearFunction)exp).getArg(0), TimeStampFunctionNode.TimePart.Year);
+ }
+ if (exp instanceof XorFunction) {
+ return addArguments(new XorFunctionNode(), (XorFunction)exp);
+ }
+ if (exp instanceof XorBitFunction) {
+ return new XorBitFunctionNode().setNumBits(((XorBitFunction)exp).getNumBits())
+ .addArg(toExpressionNode(((XorBitFunction)exp).getArg(0)));
+ }
+ if (exp instanceof YmumValue) {
+ return new GetYMUMChecksumFunctionNode();
+ }
+ throw new UnsupportedOperationException("Can not convert '" + exp + "' of class " + exp.getClass().getName() +
+ " to an expression.");
+ }
+
+ private TimeStampFunctionNode toTime(GroupingExpression arg, TimeStampFunctionNode.TimePart timePart) {
+ if (timeOffset == 0) {
+ return new TimeStampFunctionNode(toExpressionNode(arg), timePart, true);
+ }
+ AddFunctionNode exp = new AddFunctionNode();
+ exp.addArg(toExpressionNode(arg));
+ exp.addArg(new ConstantNode(new IntegerResultNode(timeOffset)));
+ return new TimeStampFunctionNode(exp, timePart, true);
+ }
+
+ private MultiArgFunctionNode addArguments(MultiArgFunctionNode ret, Iterable<GroupingExpression> lst) {
+ for (GroupingExpression exp : lst) {
+ ret.addArg(toExpressionNode(exp));
+ }
+ return ret;
+ }
+
+ private MultiArgFunctionNode toSubNode(Iterable<GroupingExpression> lst) {
+ MultiArgFunctionNode ret = new AddFunctionNode();
+ int i = 0;
+ for (GroupingExpression exp : lst) {
+ ExpressionNode node = toExpressionNode(exp);
+ if (++i > 1) {
+ node = new NegateFunctionNode(node);
+ }
+ ret.addArg(node);
+ }
+ return ret;
+ }
+
+ private ResultNodeVector toBucketList(PredefinedFunction fnc) {
+ ResultNodeVector ret = null;
+ for (int i = 0, len = fnc.getNumBuckets(); i < len; ++i) {
+ BucketResultNode bucket = toBucket(fnc.getBucket(i));
+ if (ret == null) {
+ if (bucket instanceof FloatBucketResultNode) {
+ ret = new FloatBucketResultNodeVector();
+ } else if (bucket instanceof IntegerBucketResultNode) {
+ ret = new IntegerBucketResultNodeVector();
+ } else if (bucket instanceof RawBucketResultNode) {
+ ret = new RawBucketResultNodeVector();
+ } else {
+ ret = new StringBucketResultNodeVector();
+ }
+ }
+ ret.add(bucket);
+ }
+ return ret;
+ }
+
+ private BucketResultNode toBucket(GroupingExpression exp) {
+ if (!(exp instanceof BucketValue)) {
+ throw new UnsupportedOperationException("Can not convert '" + exp + "' to a bucket.");
+ }
+ ConstantValue<?> begin = ((BucketValue)exp).getFrom();
+ ConstantValue<?> end = ((BucketValue)exp).getTo();
+ if (begin instanceof DoubleValue || end instanceof DoubleValue) {
+ return new FloatBucketResultNode(
+ begin instanceof InfiniteValue ? FloatResultNode.getNegativeInfinity().getFloat()
+ : Double.valueOf(begin.toString()),
+ end instanceof InfiniteValue ? FloatResultNode.getPositiveInfinity().getFloat()
+ : Double.valueOf(end.toString()));
+ } else if (begin instanceof LongValue || end instanceof LongValue) {
+ return new IntegerBucketResultNode(
+ begin instanceof InfiniteValue ? IntegerResultNode.getNegativeInfinity().getInteger()
+ : Long.valueOf(begin.toString()),
+ end instanceof InfiniteValue ? IntegerResultNode.getPositiveInfinity().getInteger()
+ : Long.valueOf(end.toString()));
+ } else if (begin instanceof StringValue || end instanceof StringValue) {
+ return new StringBucketResultNode(
+ begin instanceof InfiniteValue ? StringResultNode.getNegativeInfinity()
+ : new StringResultNode((String)begin.getValue()),
+ end instanceof InfiniteValue ? StringResultNode.getPositiveInfinity()
+ : new StringResultNode((String)end.getValue()));
+ } else {
+ return new RawBucketResultNode(
+ begin instanceof InfiniteValue ? RawResultNode.getNegativeInfinity()
+ : new RawResultNode(((RawValue)begin).getValue().getBytes()),
+ end instanceof InfiniteValue ? RawResultNode.getPositiveInfinity()
+ : new RawResultNode(((RawValue)end).getValue().getBytes()));
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingExecutor.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingExecutor.java
new file mode 100644
index 00000000000..e5e91f21f5f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingExecutor.java
@@ -0,0 +1,411 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.fastsearch.GroupingListHit;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.QueryCanonicalizer;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.grouping.GroupingRequest;
+import com.yahoo.search.grouping.GroupingValidator;
+import com.yahoo.search.grouping.result.Group;
+import com.yahoo.search.grouping.result.RootGroup;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.searchlib.aggregation.Grouping;
+import com.yahoo.vespa.objects.Identifiable;
+import com.yahoo.vespa.objects.ObjectOperation;
+import com.yahoo.vespa.objects.ObjectPredicate;
+
+/**
+ * Executes the {@link GroupingRequest grouping requests} set up by other searchers. This does the necessary
+ * transformation from the abstract request to Vespa grouping expressions (using {@link RequestBuilder}), and the
+ * corresponding transformation of results (using {@link ResultBuilder}).
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+@After({ GroupingValidator.GROUPING_VALIDATED,
+ "com.yahoo.search.querytransform.WandSearcher",
+ "com.yahoo.search.querytransform.BooleanSearcher" })
+@Provides({ GroupingExecutor.COMPONENT_NAME, QueryCanonicalizer.queryCanonicalization } )
+public class GroupingExecutor extends Searcher {
+
+ public final static String COMPONENT_NAME = "GroupingExecutor";
+ private final static CompoundName PROP_GROUPINGLIST = newCompoundName("GroupingList");
+ private final static Logger log = Logger.getLogger(GroupingExecutor.class.getName());
+
+ /**
+ * Constructs a new instance of this searcher without configuration.
+ * This makes the searcher completely useless for searching purposes,
+ * and should only be used for testing its logic.
+ */
+ GroupingExecutor() {
+ }
+
+ /**
+ * Constructs a new instance of this searcher with the given component id.
+ *
+ * @param componentId The identifier to assign to this searcher.
+ */
+ public GroupingExecutor(ComponentId componentId) {
+ super(componentId);
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ String error = QueryCanonicalizer.canonicalize(query);
+ if (error != null) {
+ return new Result(query, ErrorMessage.createIllegalQuery(error));
+ }
+ query.prepare();
+
+ // Retrieve grouping requests from query.
+ List<GroupingRequest> reqList = GroupingRequest.getRequests(query);
+ if (reqList.isEmpty()) {
+ return execution.search(query);
+ }
+
+ // Convert requests to Vespa style grouping.
+ Map<Integer, Grouping> groupingMap = new HashMap<>();
+ List<RequestContext> ctxList = new LinkedList<>();
+ for (GroupingRequest grpRequest : reqList) {
+ ctxList.add(convertRequest(query, grpRequest, groupingMap));
+ }
+ if (groupingMap.isEmpty()) {
+ return execution.search(query);
+ }
+
+ // Perform the necessary passes to execute grouping.
+ Result result = performSearch(query, execution, groupingMap);
+
+ // Convert Vespa style results to hits.
+ HitConverter hitConverter = new HitConverter(this, query);
+ for (RequestContext ctx : ctxList) {
+ RootGroup grp = convertResult(ctx, groupingMap, hitConverter);
+ ctx.request.setResultGroup(grp);
+ result.hits().add(grp);
+ }
+ return result;
+ }
+
+ @Override
+ public void fill(Result result, String summaryClass, Execution execution) {
+ Map<String, Result> summaryMap = new HashMap<>();
+ for (Iterator<Hit> it = result.hits().unorderedDeepIterator(); it.hasNext(); ) {
+ Hit hit = it.next();
+ Object metaData = hit.getSearcherSpecificMetaData(this);
+ String hitSummary = (metaData instanceof String) ? (String)metaData : summaryClass;
+ Result summaryResult = summaryMap.get(hitSummary);
+ if (summaryResult == null) {
+ summaryResult = new Result(result.getQuery());
+ summaryMap.put(hitSummary, summaryResult);
+ }
+ summaryResult.hits().add(hit);
+ }
+ for (Map.Entry<String, Result> entry : summaryMap.entrySet()) {
+ Result res = entry.getValue();
+ execution.fill(res, entry.getKey());
+ ErrorMessage err = res.hits().getError();
+ if (err != null) {
+ result.hits().addError(err);
+ }
+ }
+ Result defaultResult = summaryMap.get(ExpressionConverter.DEFAULT_SUMMARY_NAME);
+ if (defaultResult != null) {
+ // the reason we need to do this fix is that the docsum packet protocol uses null summary class name to
+ // signal that the backend should use its configured default, whereas for grouping it uses the literal
+ // "default" to signal the same
+ for (Hit hit : defaultResult.hits()) {
+ hit.setFilled(null);
+ }
+ }
+ }
+
+ /**
+ * Converts the given {@link GroupingRequest} into a set of {@link Grouping} objects. The returned object holds the
+ * context that corresponds to the given request, whereas the created {@link Grouping} objects are written directly
+ * to the given map.
+ *
+ * @param query The query being executed.
+ * @param req The request to convert.
+ * @param map The grouping map to write to.
+ * @return The context required to identify the request results.
+ */
+ private RequestContext convertRequest(Query query, GroupingRequest req, Map<Integer, Grouping> map) {
+ RequestBuilder builder = new RequestBuilder(req.getRequestId());
+ builder.setRootOperation(req.getRootOperation());
+ builder.setDefaultSummaryName(query.getPresentation().getSummary());
+ builder.setTimeZone(req.getTimeZone());
+ builder.addContinuations(req.continuations());
+ builder.build();
+
+ RequestContext ctx = new RequestContext(req, builder.getTransform());
+ List<Grouping> grpList = builder.getRequestList();
+ for (Grouping grp : grpList) {
+ int grpId = map.size();
+ grp.setId(grpId);
+ map.put(grpId, grp);
+ ctx.idList.add(grpId);
+ }
+ return ctx;
+ }
+
+ /**
+ * Converts the results of the given request context into a single {@link Group}.
+ *
+ * @param requestCtx The context that identifies the results to convert.
+ * @param groupingMap The map of all {@link Grouping} objects available.
+ * @param hitConverter The converter to use for {@link Hit} conversion.
+ * @return The corresponding root RootGroup.
+ */
+ private RootGroup convertResult(RequestContext requestCtx, Map<Integer, Grouping> groupingMap,
+ HitConverter hitConverter) {
+ ResultBuilder builder = new ResultBuilder();
+ builder.setHitConverter(hitConverter);
+ builder.setTransform(requestCtx.transform);
+ builder.setRequestId(requestCtx.request.getRequestId());
+ for (Integer grpId : requestCtx.idList) {
+ builder.addGroupingResult(groupingMap.get(grpId));
+ }
+ builder.build();
+ return builder.getRoot();
+ }
+
+ /**
+ * Performs the actual search passes to complete all the given {@link Grouping} requests. This method uses the
+ * grouping map argument as both an input and an output variable, as the contained {@link Grouping} objects are
+ * updates as results arrive from the back end.
+ *
+ * @param query The query to execute.
+ * @param execution The execution context used to run the queries.
+ * @param groupingMap The map of grouping requests to perform.
+ * @return The search result to pass back from this searcher.
+ */
+ private Result performSearch(Query query, Execution execution, Map<Integer, Grouping> groupingMap) {
+ // Determine how many passes to perform.
+ int lastPass = 0;
+ for (Grouping grouping : groupingMap.values()) {
+ if ( ! grouping.useSinglePass()) {
+ lastPass = Math.max(lastPass, grouping.getLevels().size());
+ }
+ }
+
+ // Perform multi-pass query to complete all grouping requests.
+ Item origRoot = query.getModel().getQueryTree().getRoot();
+ int prePassErrors = query.errors().size();
+ Result ret = null;
+ Item baseRoot = origRoot;
+ if (lastPass > 0) {
+ baseRoot = origRoot.clone();
+ }
+ if (query.isTraceable(3) && query.getGroupingSessionCache()) {
+ query.trace("Grouping in " + (lastPass + 1) + " passes. SessionId='" + query.getSessionId(true) + "'.", 3);
+ }
+ for (int pass = 0; pass <= lastPass; ++pass) {
+ boolean firstPass = (pass == 0);
+ List<Grouping> passList = getGroupingListForPassN(groupingMap, pass);
+ if (passList.isEmpty()) {
+ throw new RuntimeException("No grouping request for pass " + pass + ", bug!");
+ }
+ if (log.isLoggable(LogLevel.DEBUG)) {
+ for (Grouping grouping : passList) {
+ log.log(LogLevel.DEBUG, "Pass(" + pass + "), Grouping(" + grouping.getId() + "): " + grouping);
+ }
+ }
+ Item passRoot;
+ if (firstPass) {
+ passRoot = origRoot; // Use original query the first time.
+ } else if (pass == lastPass) {
+ passRoot = baseRoot; // Has already been cloned once, use this for last pass.
+ } else {
+ // noinspection ConstantConditions
+ passRoot = baseRoot.clone();
+ }
+ if (query.isTraceable(4) && query.getGroupingSessionCache()) {
+ query.trace("Grouping with session cache '" + query.getGroupingSessionCache() + "' enabled for pass #" + pass + ".", 4);
+ }
+ if (origRoot != passRoot) {
+ query.getModel().getQueryTree().setRoot(passRoot);
+ }
+ setGroupingList(query, passList);
+ Result passResult = execution.search(query);
+ if (passResult.hits().getError() != null) {
+ if (firstPass) {
+ if (passResult.hits().getErrorHit().errors().size() > prePassErrors ||
+ passResult.hits().getErrorHit().errors().size() == 0) {
+ return passResult;
+ }
+ } else {
+ return passResult;
+ }
+ }
+ Map<Integer, Grouping> passGroupingMap = mergeGroupingResults(passResult);
+ mergeGroupingMaps(groupingMap, passGroupingMap);
+ if (firstPass) {
+ ret = passResult;
+ }
+ }
+ if (log.isLoggable(LogLevel.DEBUG)) {
+ for (Grouping grouping : groupingMap.values()) {
+ log.log(LogLevel.DEBUG, "Result Grouping(" + grouping.getId() + "): " + grouping);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Merges the content of result into state. This needs to be done in order to conserve the context objects contained
+ * in the state as they are not part of the serialized object representation.
+ *
+ * @param state the current state.
+ * @param result the results from the current pass.
+ */
+ private void mergeGroupingMaps(Map<Integer, Grouping> state, Map<Integer, Grouping> result) {
+ for (Grouping grouping : result.values()) {
+ Grouping old = state.get(grouping.getId());
+ if (old != null) {
+ old.merge(grouping);
+ // no need to invoke postMerge, as state is empty for
+ // current level
+ } else {
+ log.warning("Got grouping result with unknown id: " + grouping);
+ }
+ }
+ }
+
+ /**
+ * Returns a list of {@link Grouping} objects that are to be used for the given pass.
+ *
+ * @param groupingMap The map of all grouping objects.
+ * @param pass The pass about to be performed.
+ * @return A list of grouping objects.
+ */
+ private List<Grouping> getGroupingListForPassN(Map<Integer, Grouping> groupingMap, int pass) {
+ List<Grouping> ret = new ArrayList<>();
+ for (Grouping grouping : groupingMap.values()) {
+ if (grouping.useSinglePass()) {
+ if (pass == 0) {
+ grouping.setFirstLevel(0);
+ grouping.setLastLevel(grouping.getLevels().size());
+ ret.add(grouping); // more levels to go
+ }
+ } else {
+ if (pass <= grouping.getLevels().size()) {
+ grouping.setFirstLevel(pass);
+ grouping.setLastLevel(pass);
+ ret.add(grouping); // more levels to go
+ }
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Merges the grouping content of the given result object. The first grouping hit found by iterating over the result
+ * content is kept, and all consecutive matching hits are merged into this.
+ *
+ * @param result The result to traverse.
+ * @return A map of merged grouping objects.
+ */
+ private Map<Integer, Grouping> mergeGroupingResults(Result result) {
+ Map<Integer, Grouping> ret = new HashMap<>();
+ for (Iterator<Hit> i = result.hits().unorderedIterator(); i.hasNext(); ) {
+ Hit hit = i.next();
+ if (hit instanceof GroupingListHit) {
+ ContextInjector injector = new ContextInjector(hit);
+ for (Grouping grp : ((GroupingListHit)hit).getGroupingList()) {
+ grp.select(injector, injector);
+ Grouping old = ret.get(grp.getId());
+ if (old != null) {
+ old.merge(grp);
+ } else {
+ ret.put(grp.getId(), grp);
+ }
+ }
+ i.remove();
+ }
+ }
+ for (Grouping grouping : ret.values()) {
+ grouping.postMerge();
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the list of {@link Grouping} objects assigned to the given query. If no list has been assigned, this
+ * method returns an empty list.
+ *
+ * @param query The query whose grouping list to return.
+ * @return The list of assigned grouping objects.
+ */
+ @SuppressWarnings({ "unchecked" })
+ public static List<Grouping> getGroupingList(Query query) {
+ Object obj = query.properties().get(PROP_GROUPINGLIST);
+ if (!(obj instanceof List)) {
+ return Collections.emptyList();
+ }
+ return (List<Grouping>)obj;
+ }
+
+ /**
+ * Sets the list of {@link Grouping} objects assigned to the given query. This method overwrites any grouping
+ * objects already assigned to the query.
+ *
+ * @param query The query whose grouping list to set.
+ * @param lst The grouping list to set.
+ */
+ public static void setGroupingList(Query query, List<Grouping> lst) {
+ query.properties().set(PROP_GROUPINGLIST, lst);
+ }
+
+ private static CompoundName newCompoundName(String name) {
+ return new CompoundName(GroupingExecutor.class.getName() + "." + name);
+ }
+
+ private static class ContextInjector implements ObjectPredicate, ObjectOperation {
+
+ final Object context;
+
+ ContextInjector(Object context) {
+ this.context = context;
+ }
+
+ @Override
+ public boolean check(Object obj) {
+ return com.yahoo.searchlib.aggregation.Hit.class.isInstance(obj);
+ }
+
+ @Override
+ public void execute(Object obj) {
+ ((com.yahoo.searchlib.aggregation.Hit)obj).setContext(context);
+ }
+ }
+
+ private static class RequestContext {
+
+ final List<Integer> idList = new LinkedList<>();
+ final GroupingRequest request;
+ final GroupingTransform transform;
+
+ RequestContext(GroupingRequest request, GroupingTransform transform) {
+ this.request = request;
+ this.transform = transform;
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingTransform.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingTransform.java
new file mode 100644
index 00000000000..928b0ebd22f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/GroupingTransform.java
@@ -0,0 +1,137 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.Continuation;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * This class contains enough information about how a {@link com.yahoo.search.grouping.request.GroupingOperation} was
+ * transformed into a list {@link com.yahoo.searchlib.aggregation.Grouping} objects, so that the results of those
+ * queries can be transformed into something that corresponds to the original request.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class GroupingTransform {
+
+ private final Map<Integer, Set<Integer>> children = new HashMap<>();
+ private final Map<Integer, String> labels = new HashMap<>();
+ private final Map<Integer, Integer> maxes = new HashMap<>();
+ private final Map<Integer, Integer> offsetByTag = new HashMap<>();
+ private final Map<ResultId, Integer> offsetById = new HashMap<>();
+ private final Set<ResultId> unstable = new HashSet<>();
+ private final int requestId;
+
+ public GroupingTransform(int requestId) {
+ this.requestId = requestId;
+ }
+
+ public GroupingTransform addContinuation(Continuation cont) {
+ if (cont instanceof CompositeContinuation) {
+ for (Continuation item : ((CompositeContinuation)cont)) {
+ addContinuation(item);
+ }
+ } else if (cont instanceof OffsetContinuation) {
+ OffsetContinuation offsetCont = (OffsetContinuation)cont;
+ ResultId id = offsetCont.getResultId();
+ if (!id.startsWith(requestId)) {
+ return this;
+ }
+ if (offsetCont.testFlag(OffsetContinuation.FLAG_UNSTABLE)) {
+ unstable.add(id);
+ } else {
+ unstable.remove(id);
+ }
+ int tag = offsetCont.getTag();
+ int offset = offsetCont.getOffset();
+ if (getOffset(tag) < offset) {
+ offsetByTag.put(tag, offset);
+ }
+ offsetById.put(id, offset);
+ } else {
+ throw new UnsupportedOperationException(cont.getClass().getName());
+ }
+ return this;
+ }
+
+ public boolean isStable(ResultId resultId) {
+ return !unstable.contains(resultId);
+ }
+
+ public int getOffset(int tag) {
+ return toPosInt(offsetByTag.get(tag));
+ }
+
+ public int getOffset(ResultId resultId) {
+ return toPosInt(offsetById.get(resultId));
+ }
+
+ public GroupingTransform putMax(int tag, int max, String type) {
+ if (maxes.containsKey(tag)) {
+ throw new IllegalStateException("Can not set max of " + type + " " + tag + " to " + max +
+ " because it is already set to " + maxes.get(tag) + ".");
+ }
+ maxes.put(tag, max);
+ return this;
+ }
+
+ public int getMax(int tag) {
+ return toPosInt(maxes.get(tag));
+ }
+
+ public GroupingTransform putLabel(int parentTag, int tag, String label, String type) {
+ Set<Integer> siblings = children.get(parentTag);
+ if (siblings == null) {
+ siblings = new HashSet<>();
+ children.put(parentTag, siblings);
+ } else {
+ for (Integer sibling : siblings) {
+ if (label.equals(labels.get(sibling))) {
+ throw new UnsupportedOperationException("Can not use " + type + " label '" + label +
+ "' for multiple siblings.");
+ }
+ }
+ }
+ siblings.add(tag);
+ if (labels.containsKey(tag)) {
+ throw new IllegalStateException("Can not set label of " + type + " " + tag + " to '" + label +
+ "' because it is already set to '" + labels.get(tag) + "'.");
+ }
+ labels.put(tag, label);
+ return this;
+ }
+
+ public String getLabel(int tag) {
+ return labels.get(tag);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder ret = new StringBuilder();
+ ret.append("groupingTransform {\n");
+ ret.append("\tlabels {\n");
+ for (Map.Entry<Integer, String> entry : labels.entrySet()) {
+ ret.append("\t\t").append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n");
+ }
+ ret.append("\t}\n");
+ ret.append("\toffsets {\n");
+ for (Map.Entry<Integer, Integer> entry : offsetByTag.entrySet()) {
+ ret.append("\t\t").append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n");
+ }
+ ret.append("\t}\n");
+ ret.append("\tmaxes {\n");
+ for (Map.Entry<Integer, Integer> entry : maxes.entrySet()) {
+ ret.append("\t\t").append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n");
+ }
+ ret.append("\t}\n");
+ ret.append("}");
+ return ret.toString();
+ }
+
+ private static int toPosInt(Integer val) {
+ return val == null ? 0 : Math.max(0, val.intValue());
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/HitConverter.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/HitConverter.java
new file mode 100644
index 00000000000..81ae100b84f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/HitConverter.java
@@ -0,0 +1,89 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.fs4.QueryPacketData;
+import com.yahoo.prelude.fastsearch.DocsumDefinitionSet;
+import com.yahoo.prelude.fastsearch.FastHit;
+import com.yahoo.prelude.fastsearch.GroupingListHit;
+import com.yahoo.search.Query;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.Hit;
+import com.yahoo.searchlib.aggregation.FS4Hit;
+import com.yahoo.searchlib.aggregation.VdsHit;
+
+/**
+ * Implementation of the {@link ResultBuilder.HitConverter} interface for {@link GroupingExecutor}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class HitConverter implements ResultBuilder.HitConverter {
+
+ private final Searcher searcher;
+ private final Query query;
+
+ /**
+ * Creates a new instance of this class.
+ *
+ * @param searcher The searcher that owns this converter.
+ * @param query The query that returned the hits.
+ */
+ public HitConverter(Searcher searcher, Query query) {
+ this.searcher = searcher;
+ this.query = query;
+ }
+
+ @Override
+ public com.yahoo.search.result.Hit toSearchHit(String summaryClass, com.yahoo.searchlib.aggregation.Hit hit) {
+ if (hit instanceof FS4Hit) {
+ return convertFs4Hit(summaryClass, (FS4Hit)hit);
+ } else if (hit instanceof VdsHit) {
+ return convertVdsHit(summaryClass, (VdsHit)hit);
+ } else {
+ throw new UnsupportedOperationException("Hit type '" + hit.getClass().getName() + "' not supported.");
+ }
+ }
+
+ private Hit convertFs4Hit(String summaryClass, FS4Hit grpHit) {
+ FastHit ret = new FastHit();
+ ret.setRelevance(grpHit.getRank());
+ ret.setGlobalId(grpHit.getGlobalId());
+ ret.setPartId(grpHit.getPath(), 0);
+ ret.setDistributionKey(grpHit.getDistributionKey());
+ ret.setFillable();
+ ret.setSearcherSpecificMetaData(searcher, summaryClass);
+
+ Hit ctxHit = (Hit)grpHit.getContext();
+ if (ctxHit == null) {
+ throw new NullPointerException("Hit has no context.");
+ }
+ ret.setSource(ctxHit.getSource());
+ ret.setSourceNumber(ctxHit.getSourceNumber());
+ ret.setQuery(ctxHit.getQuery());
+
+ if (ctxHit instanceof GroupingListHit) {
+ // in a live system the ctxHit can only by GroupingListHit, but because the code used Hit prior to version
+ // 5.10 we need to check to avoid breaking existing unit tests -- both internally and with customers
+ QueryPacketData queryPacketData = ((GroupingListHit)ctxHit).getQueryPacketData();
+ if (queryPacketData != null) {
+ ret.setQueryPacketData(queryPacketData);
+ }
+ }
+ return ret;
+ }
+
+ private Hit convertVdsHit(String summaryClass, VdsHit grpHit) {
+ FastHit ret = new FastHit();
+ ret.setRelevance(grpHit.getRank());
+ if (grpHit.getSummary().getData().length > 0) {
+ GroupingListHit ctxHit = (GroupingListHit)grpHit.getContext();
+ if (ctxHit == null) {
+ throw new NullPointerException("Hit has no context.");
+ }
+ DocsumDefinitionSet defs = ctxHit.getDocsumDefinitionSet();
+ defs.lazyDecode(summaryClass, grpHit.getSummary().getData(), ret);
+ ret.setFilled(summaryClass);
+ ret.setFilled(query.getPresentation().getSummary());
+ }
+ return ret;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerDecoder.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerDecoder.java
new file mode 100644
index 00000000000..c398fb41db2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerDecoder.java
@@ -0,0 +1,39 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class IntegerDecoder {
+
+ private static final int CHAR_MIN = IntegerEncoder.CHARS[0];
+ private static final int CHAR_MAX = IntegerEncoder.CHARS[IntegerEncoder.CHARS.length - 1];
+ private final String input;
+ private int pos = 0;
+
+ public IntegerDecoder(String input) {
+ this.input = input;
+ }
+
+ public boolean hasNext() {
+ return pos < input.length();
+ }
+
+ public int next() {
+ int val = 0;
+ int len = decodeChar(input.charAt(pos++));
+ for (int i = 0; i < len; i++) {
+ val = (val << 4) | decodeChar(input.charAt(pos + i));
+ }
+ pos += len;
+ return (val >>> 1) ^ (-(val & 0x1));
+ }
+
+ private static int decodeChar(char c) {
+ if (c >= CHAR_MIN && c <= CHAR_MAX) {
+ return (0xF & (c - CHAR_MIN));
+ } else {
+ throw new NumberFormatException(String.valueOf(c));
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerEncoder.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerEncoder.java
new file mode 100644
index 00000000000..c710905a0c8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/IntegerEncoder.java
@@ -0,0 +1,32 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class IntegerEncoder {
+
+ public static final char[] CHARS = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H',
+ 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P' };
+ private final StringBuilder out = new StringBuilder();
+
+ public void append(int val) {
+ val = ((val << 1) ^ (val >> 31));
+ int cnt = 8;
+ for (int i = 0; i < 8; ++i) {
+ if (((val >> (28 - 4 * i)) & 0xF) != 0) {
+ break;
+ }
+ --cnt;
+ }
+ out.append(CHARS[cnt]);
+ for (int i = 8 - cnt; i < 8; ++i) {
+ out.append(CHARS[(val >> (28 - 4 * i)) & 0xF]);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return out.toString();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/OffsetContinuation.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/OffsetContinuation.java
new file mode 100644
index 00000000000..789be271c5c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/OffsetContinuation.java
@@ -0,0 +1,84 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class OffsetContinuation extends EncodableContinuation {
+
+ public static final int FLAG_UNSTABLE = 1;
+ private final ResultId resultId;
+ private final int tag;
+ private final int offset;
+ private final int flags;
+
+ public OffsetContinuation(ResultId resultId, int tag, int offset, int flags) {
+ resultId.getClass(); // throws NullPointerException
+ this.resultId = resultId;
+ this.tag = tag;
+ this.offset = offset;
+ this.flags = flags;
+ }
+
+ public ResultId getResultId() {
+ return resultId;
+ }
+
+ public int getTag() {
+ return tag;
+ }
+
+ public int getOffset() {
+ return offset;
+ }
+
+ public int getFlags() {
+ return flags;
+ }
+
+ public boolean testFlag(int flag) {
+ return (flags & flag) != 0;
+ }
+
+ @Override
+ public int hashCode() {
+ return resultId.hashCode() + offset + flags;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof OffsetContinuation)) {
+ return false;
+ }
+ OffsetContinuation rhs = (OffsetContinuation)obj;
+ if (!resultId.equals(rhs.resultId)) {
+ return false;
+ }
+ if (tag != rhs.tag) {
+ return false;
+ }
+ if (offset != rhs.offset) {
+ return false;
+ }
+ if (flags != rhs.flags) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void encode(IntegerEncoder out) {
+ resultId.encode(out);
+ out.append(tag);
+ out.append(offset);
+ out.append(flags);
+ }
+
+ public static OffsetContinuation decode(IntegerDecoder in) {
+ ResultId resultId = ResultId.decode(in);
+ int tag = in.next();
+ int offset = in.next();
+ int flags = in.next();
+ return new OffsetContinuation(resultId, tag, offset, flags);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/RequestBuilder.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/RequestBuilder.java
new file mode 100644
index 00000000000..9d47464b1de
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/RequestBuilder.java
@@ -0,0 +1,397 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.grouping.GroupingRequest;
+import com.yahoo.search.grouping.request.EachOperation;
+import com.yahoo.search.grouping.request.GroupingExpression;
+import com.yahoo.search.grouping.request.GroupingOperation;
+import com.yahoo.search.grouping.request.NegFunction;
+import com.yahoo.searchlib.aggregation.*;
+import com.yahoo.searchlib.expression.ExpressionNode;
+
+import java.util.*;
+
+/**
+ * This class implements the necessary logic to build a list of {@link Grouping} objects from an instance of {@link
+ * GroupingOperation}. It is used by the {@link GroupingExecutor}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class RequestBuilder {
+
+ private static final int LOOKAHEAD = 1;
+ private final ExpressionConverter converter = new ExpressionConverter();
+ private final List<Grouping> requestList = new LinkedList<>();
+ private final GroupingTransform transform;
+ private GroupingOperation root;
+ private int tag = 0;
+
+ /**
+ * Constructs a new instance of this class.
+ *
+ * @param requestId The id of the corresponding {@link GroupingRequest}.
+ */
+ public RequestBuilder(int requestId) {
+ this.transform = new GroupingTransform(requestId);
+ }
+
+ /**
+ * Sets the abstract syntax tree of the request whose back-end queries to create.
+ *
+ * @param root The grouping request to convert.
+ * @return This, to allow chaining.
+ */
+ public RequestBuilder setRootOperation(GroupingOperation root) {
+ root.getClass(); // throws NullPointerException
+ this.root = root;
+ return this;
+ }
+
+ /**
+ * Sets the time zone to build the request for. This information is propagated to the time-based grouping
+ * expressions so that the produced groups are reasonable for the given zone.
+ *
+ * @param timeZone The time zone to set.
+ * @return This, to allow chaining.
+ */
+ public RequestBuilder setTimeZone(TimeZone timeZone) {
+ converter.setTimeOffset(timeZone != null ? timeZone.getOffset(System.currentTimeMillis())
+ : ExpressionConverter.DEFAULT_TIME_OFFSET);
+ return this;
+ }
+
+ /**
+ * Sets the name of the summary class to use if a {@link com.yahoo.search.grouping.request.SummaryValue} has none.
+ *
+ * @param summaryName The summary class name to set.
+ * @return This, to allow chaining.
+ */
+ public RequestBuilder setDefaultSummaryName(String summaryName) {
+ converter.setDefaultSummaryName(summaryName != null ? summaryName
+ : ExpressionConverter.DEFAULT_SUMMARY_NAME);
+ return this;
+ }
+
+ /**
+ * Returns the transform that was created when {@link #build()} was called.
+ *
+ * @return The grouping transform that was built.
+ */
+ public GroupingTransform getTransform() {
+ return transform;
+ }
+
+ /**
+ * Returns the list of grouping objects that were created when {@link #build()} was called.
+ *
+ * @return The list of built grouping objects.
+ */
+ public List<Grouping> getRequestList() {
+ return requestList;
+ }
+
+ /**
+ * Constructs a set of Vespa specific grouping request that corresponds to the parameters given to this builder.
+ * This method might fail due to unsupported constructs in the request, in which case an exception is thrown.
+ *
+ * @throws IllegalStateException If this method is called more than once.
+ * @throws UnsupportedOperationException If the grouping request contains unsupported constructs.
+ */
+ public void build() {
+ if (tag != 0) {
+ throw new IllegalStateException();
+ }
+ root.resolveLevel(1);
+
+ Grouping grouping = new Grouping();
+ grouping.getRoot().setTag(++tag);
+ grouping.setForceSinglePass(root.getForceSinglePass() || root.containsHint("singlepass"));
+ Stack<BuildFrame> stack = new Stack<>();
+ stack.push(new BuildFrame(grouping, new BuildState(), root));
+ while (!stack.isEmpty()) {
+ BuildFrame frame = stack.pop();
+ processRequestNode(frame);
+ List<GroupingOperation> children = frame.astNode.getChildren();
+ if (children.isEmpty()) {
+ requestList.add(frame.grouping);
+ } else {
+ for (int i = children.size(); --i >= 0; ) {
+ Grouping childGrouping = (i == 0) ? frame.grouping : frame.grouping.clone();
+ BuildState childState = (i == 0) ? frame.state : new BuildState(frame.state);
+ BuildFrame child = new BuildFrame(childGrouping, childState, children.get(i));
+ stack.push(child);
+ }
+ }
+ }
+ pruneRequests();
+ }
+
+ public RequestBuilder addContinuations(Iterable<Continuation> continuations) {
+ for (Continuation continuation : continuations) {
+ if (continuation == null) {
+ continue;
+ }
+ transform.addContinuation(continuation);
+ }
+ return this;
+ }
+
+ private void processRequestNode(BuildFrame frame) {
+ int level = frame.astNode.getLevel();
+ if (level > 2) {
+ throw new UnsupportedOperationException("Can not operate on " +
+ GroupingOperation.getLevelDesc(level) + ".");
+ }
+ if (frame.astNode instanceof EachOperation) {
+ resolveEach(frame);
+ } else {
+ resolveOutput(frame);
+ }
+ resolveState(frame);
+ injectGroupByToExpressionCountAggregator(frame);
+ }
+
+ private void injectGroupByToExpressionCountAggregator(BuildFrame frame) {
+ Group group = getLeafGroup(frame);
+ // The ExpressionCountAggregationResult uses the group-by expression to simulate aggregation of list of groups.
+ group.getAggregationResults().stream()
+ .filter(aggr -> aggr instanceof ExpressionCountAggregationResult)
+ .forEach(aggr -> aggr.setExpression(frame.state.groupBy.clone()));
+ }
+
+ private void resolveEach(BuildFrame frame) {
+ int parentTag = getLeafGroup(frame).getTag();
+ if (frame.state.groupBy != null) {
+ GroupingLevel grpLevel = new GroupingLevel();
+ grpLevel.getGroupPrototype().setTag(++tag);
+ grpLevel.setExpression(frame.state.groupBy);
+ frame.state.groupBy = null;
+ int offset = transform.getOffset(tag);
+ if (frame.state.precision != null) {
+ grpLevel.setPrecision(frame.state.precision + offset);
+ frame.state.precision = null;
+ }
+ if (frame.state.max != null) {
+ transform.putMax(tag, frame.state.max, "group list");
+ grpLevel.setMaxGroups(LOOKAHEAD + frame.state.max + offset);
+ frame.state.max = null;
+ }
+ frame.grouping.getLevels().add(grpLevel);
+ }
+ String label = frame.astNode.getLabel();
+ if (label != null) {
+ frame.state.label = label;
+ }
+ if (frame.astNode.getLevel() > 0) {
+ transform.putLabel(parentTag, getLeafGroup(frame).getTag(), frame.state.label, "group list");
+ }
+ resolveOutput(frame);
+ if (!frame.state.orderByExp.isEmpty()) {
+ GroupingLevel grpLevel = getLeafGroupingLevel(frame);
+ for (int i = 0, len = frame.state.orderByExp.size(); i < len; ++i) {
+ grpLevel.getGroupPrototype().addOrderBy(frame.state.orderByExp.get(i),
+ frame.state.orderByAsc.get(i));
+ }
+ frame.state.orderByExp.clear();
+ frame.state.orderByAsc.clear();
+ }
+ }
+
+ private void resolveState(BuildFrame frame) {
+ resolveGroupBy(frame);
+ resolveMax(frame);
+ resolveOrderBy(frame);
+ resolvePrecision(frame);
+ resolveWhere(frame);
+ }
+
+ private void resolveGroupBy(BuildFrame frame) {
+ GroupingExpression exp = frame.astNode.getGroupBy();
+ if (exp != null) {
+ if (frame.state.groupBy != null) {
+ throw new UnsupportedOperationException("Can not group list of groups.");
+ }
+ frame.state.groupBy = converter.toExpressionNode(exp);
+ frame.state.label = exp.toString(); // label for next each()
+
+ } else {
+ int level = frame.astNode.getLevel();
+ if (level == 0) {
+ // no next each()
+ } else if (level == 1) {
+ frame.state.label = "hits"; // next each() is hitlist
+ } else {
+ throw new UnsupportedOperationException("Can not create anonymous " +
+ GroupingOperation.getLevelDesc(level) + ".");
+ }
+ }
+ }
+
+ private void resolveMax(BuildFrame frame) {
+
+ if (frame.astNode.hasMax()) {
+ int max = frame.astNode.getMax();
+ if (isRootOperation(frame)) {
+ frame.grouping.setTopN(max);
+ } else {
+ frame.state.max = max;
+ }
+ }
+ }
+
+ private void resolveOrderBy(BuildFrame frame) {
+ List<GroupingExpression> lst = frame.astNode.getOrderBy();
+ if (lst == null || lst.isEmpty()) {
+ return;
+ }
+ int reqLevel = frame.astNode.getLevel();
+ if (reqLevel != 2) {
+ throw new UnsupportedOperationException(
+ "Can not order " + GroupingOperation.getLevelDesc(reqLevel) + " content.");
+ }
+ for (GroupingExpression exp : lst) {
+ boolean asc = true;
+ if (exp instanceof NegFunction) {
+ asc = false;
+ exp = ((NegFunction)exp).getArg(0);
+ }
+ frame.state.orderByExp.add(converter.toExpressionNode(exp));
+ frame.state.orderByAsc.add(asc);
+ }
+ }
+
+ private void resolveOutput(BuildFrame frame) {
+ List<GroupingExpression> lst = frame.astNode.getOutputs();
+ if (lst == null || lst.isEmpty()) {
+ return;
+ }
+ Group group = getLeafGroup(frame);
+ for (GroupingExpression exp : lst) {
+ group.addAggregationResult(toAggregationResult(exp, group, frame));
+ }
+ }
+
+ private AggregationResult toAggregationResult(GroupingExpression exp, Group group, BuildFrame frame) {
+ AggregationResult result = converter.toAggregationResult(exp);
+ result.setTag(++tag);
+
+ String label = exp.getLabel();
+ if (result instanceof HitsAggregationResult) {
+ if (label != null) {
+ throw new UnsupportedOperationException("Can not label expression '" + exp + "'.");
+ }
+ HitsAggregationResult hits = (HitsAggregationResult)result;
+ if (frame.state.max != null) {
+ transform.putMax(tag, frame.state.max, "hit list");
+ int offset = transform.getOffset(tag);
+ hits.setMaxHits(LOOKAHEAD + frame.state.max + offset);
+ frame.state.max = null;
+ }
+ transform.putLabel(group.getTag(), tag, frame.state.label, "hit list");
+ } else {
+ transform.putLabel(group.getTag(), tag, label != null ? label : exp.toString(), "output");
+ }
+ return result;
+ }
+
+ private void resolvePrecision(BuildFrame frame) {
+ int precision = frame.astNode.getPrecision();
+ if (precision > 0) {
+ frame.state.precision = precision;
+ }
+ }
+
+ private void resolveWhere(BuildFrame frame) {
+ String where = frame.astNode.getWhere();
+ if (where != null) {
+ if (!isRootOperation(frame)) {
+ throw new UnsupportedOperationException("Can not apply 'where' to non-root group.");
+ }
+ switch (where) {
+ case "true":
+ frame.grouping.setAll(true);
+ break;
+ case "$query":
+ // ignore
+ break;
+ default:
+ throw new UnsupportedOperationException("Operation 'where' does not support '" + where + "'.");
+ }
+ }
+ }
+
+ private boolean isRootOperation(BuildFrame frame) {
+ return frame.astNode == root && frame.state.groupBy == null;
+ }
+
+ private GroupingLevel getLeafGroupingLevel(BuildFrame frame) {
+ if (frame.grouping.getLevels().isEmpty()) {
+ return null;
+ }
+ return frame.grouping.getLevels().get(frame.grouping.getLevels().size() - 1);
+ }
+
+ private Group getLeafGroup(BuildFrame frame) {
+ if (frame.grouping.getLevels().isEmpty()) {
+ return frame.grouping.getRoot();
+ } else {
+ GroupingLevel grpLevel = getLeafGroupingLevel(frame);
+ return grpLevel != null ? grpLevel.getGroupPrototype() : null;
+ }
+ }
+
+ private void pruneRequests() {
+ for (int reqIdx = requestList.size(); --reqIdx >= 0; ) {
+ Grouping request = requestList.get(reqIdx);
+ List<GroupingLevel> lst = request.getLevels();
+ for (int lvlIdx = lst.size(); --lvlIdx >= 0; ) {
+ if (!lst.get(lvlIdx).getGroupPrototype().getAggregationResults().isEmpty()) {
+ break;
+ }
+ lst.remove(lvlIdx);
+ }
+ if (lst.isEmpty() && request.getRoot().getAggregationResults().isEmpty()) {
+ requestList.remove(reqIdx);
+ }
+ }
+ }
+
+ private static class BuildFrame {
+
+ final Grouping grouping;
+ final BuildState state;
+ final GroupingOperation astNode;
+
+ BuildFrame(Grouping grouping, BuildState state, GroupingOperation astNode) {
+ this.grouping = grouping;
+ this.state = state;
+ this.astNode = astNode;
+ }
+ }
+
+ private static class BuildState {
+
+ final List<ExpressionNode> orderByExp = new ArrayList<>();
+ final List<Boolean> orderByAsc = new ArrayList<>();
+ ExpressionNode groupBy = null;
+ String label = null;
+ Integer max = null;
+ Integer precision = null;
+
+ BuildState() {
+ // empty
+ }
+
+ BuildState(BuildState obj) {
+ for (ExpressionNode e : obj.orderByExp) {
+ orderByExp.add(e.clone());
+ }
+ orderByAsc.addAll(obj.orderByAsc);
+ groupBy = obj.groupBy;
+ label = obj.label;
+ max = obj.max;
+ precision = obj.precision;
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultBuilder.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultBuilder.java
new file mode 100644
index 00000000000..590b531812a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultBuilder.java
@@ -0,0 +1,353 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.grouping.GroupingRequest;
+import com.yahoo.search.grouping.result.*;
+import com.yahoo.search.grouping.result.Group;
+import com.yahoo.search.result.Relevance;
+import com.yahoo.searchlib.aggregation.*;
+import com.yahoo.searchlib.expression.*;
+
+import java.util.*;
+
+/**
+ * This class implements the necessary logic to build a {@link RootGroup} from a list of {@link Grouping} objects. It is
+ * used by the {@link GroupingExecutor}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class ResultBuilder {
+
+ private final CompositeContinuation continuation = new CompositeContinuation();
+ private RootGroup root;
+ private GroupListBuilder rootBuilder;
+ private HitConverter hitConverter;
+ private GroupingTransform transform;
+
+ /**
+ * Sets the id of the {@link GroupingRequest} that this builder is creating the result for.
+ *
+ * @param requestId The id of the corresponding GroupingRequest.
+ * @return This, to allow chaining.
+ */
+ public ResultBuilder setRequestId(int requestId) {
+ root = new RootGroup(requestId, continuation);
+ rootBuilder = new GroupListBuilder(ResultId.valueOf(requestId), 0, true, true);
+ return this;
+ }
+
+ /**
+ * Sets the transform that details how the result should be built.
+ *
+ * @param transform The transform to set.
+ * @return This, to allow chaining.
+ */
+ public ResultBuilder setTransform(GroupingTransform transform) {
+ this.transform = transform;
+ return this;
+ }
+
+ /**
+ * Sets the converts that details how hits are converted.
+ *
+ * @param hitConverter The converter to set.
+ * @return This, to allow chaining.
+ */
+ public ResultBuilder setHitConverter(HitConverter hitConverter) {
+ this.hitConverter = hitConverter;
+ return this;
+ }
+
+ /**
+ * Adds a grouping result to this transform. This method will recurse through the given object and retrieve all the
+ * information it needs to produce the desired result when calling {@link #build()}.
+ *
+ * @param executionResult The grouping result to process.
+ */
+ public void addGroupingResult(Grouping executionResult) {
+ executionResult.unifyNull();
+ rootBuilder.addGroup(executionResult.getRoot());
+ }
+
+ /**
+ * Returns the root {@link RootGroup} that was created when {@link #build()} was called.
+ *
+ * @return The root that was built.
+ */
+ public RootGroup getRoot() {
+ return root;
+ }
+
+ /**
+ * Returns the {@link Continuation} that would recreate the exact same result as this. It is not complete until
+ * {@link #build()} has been called.
+ *
+ * @return The continuation of this result.
+ */
+ public Continuation getContinuation() {
+ return continuation;
+ }
+
+ /**
+ * Constructs the grouping result tree that corresponds to the parameters given to this builder. This method might
+ * fail due to unsupported constructs in the results, in which case an exception is thrown.
+ *
+ * @throws UnsupportedOperationException Thrown if the grouping result contains unsupported constructs.
+ */
+ public void build() {
+ int numChildren = rootBuilder.childGroups.size();
+ if (numChildren != 1) {
+ throw new UnsupportedOperationException("Expected 1 group, got " + numChildren + ".");
+ }
+ rootBuilder.childGroups.get(0).fill(root);
+ }
+
+ private class GroupBuilder {
+
+ boolean [] results = new boolean[8];
+ GroupListBuilder [] childLists = new GroupListBuilder[8];
+ int childCount = 0;
+ final ResultId resultId;
+ final com.yahoo.searchlib.aggregation.Group group;
+ final boolean stable;
+
+ GroupBuilder(ResultId resultId, com.yahoo.searchlib.aggregation.Group group, boolean stable) {
+ this.resultId = resultId;
+ this.group = group;
+ this.stable = stable;
+ }
+
+ Group build(double relevance) {
+ return fill(new Group(newGroupId(group), new Relevance(relevance)));
+ }
+
+ Group fill(Group group) {
+ for (AggregationResult res : this.group.getAggregationResults()) {
+ int tag = res.getTag();
+ if (res instanceof HitsAggregationResult) {
+ group.add(newHitList(group.size(), tag, (HitsAggregationResult)res));
+ } else {
+ String label = transform.getLabel(res.getTag());
+ if (label != null) {
+ group.setField(label, newResult(res, tag));
+ }
+ }
+ }
+ for (GroupListBuilder child : childLists) {
+ if (child != null) {
+ group.add(child.build());
+ }
+ }
+ return group;
+ }
+
+ GroupListBuilder getOrCreateChildList(int tag, boolean ranked) {
+ int index = tag + 1; // Add 1 to avoid the dreaded -1 default value.
+ if (index >= childLists.length) {
+ childLists = Arrays.copyOf(childLists, tag + 8);
+ }
+ GroupListBuilder ret = childLists[index];
+ if (ret == null) {
+ ret = new GroupListBuilder(resultId.newChildId(childCount), tag, stable, ranked);
+ childLists[index] = ret;
+ childCount++;
+ }
+ return ret;
+ }
+
+ void merge(com.yahoo.searchlib.aggregation.Group group) {
+ for (AggregationResult res : group.getAggregationResults()) {
+ int tag = res.getTag() + 1; // Add 1 due to dreaded -1 initialization as default.
+ if (tag >= results.length) {
+ results = Arrays.copyOf(results, tag+8);
+ }
+ if ( ! results[tag] ) {
+ this.group.getAggregationResults().add(res);
+ results[tag] = true;
+ }
+ }
+ }
+
+ GroupId newGroupId(com.yahoo.searchlib.aggregation.Group execGroup) {
+ ResultNode res = execGroup.getId();
+ if (res instanceof FloatResultNode) {
+ return new DoubleId(res.getFloat());
+ } else if (res instanceof IntegerResultNode) {
+ return new LongId(res.getInteger());
+ } else if (res instanceof NullResultNode) {
+ return new NullId();
+ } else if (res instanceof RawResultNode) {
+ return new RawId(res.getRaw());
+ } else if (res instanceof StringResultNode) {
+ return new StringId(res.getString());
+ } else if (res instanceof FloatBucketResultNode) {
+ FloatBucketResultNode bucketId = (FloatBucketResultNode)res;
+ return new DoubleBucketId(bucketId.getFrom(), bucketId.getTo());
+ } else if (res instanceof IntegerBucketResultNode) {
+ IntegerBucketResultNode bucketId = (IntegerBucketResultNode)res;
+ return new LongBucketId(bucketId.getFrom(), bucketId.getTo());
+ } else if (res instanceof StringBucketResultNode) {
+ StringBucketResultNode bucketId = (StringBucketResultNode)res;
+ return new StringBucketId(bucketId.getFrom(), bucketId.getTo());
+ } else if (res instanceof RawBucketResultNode) {
+ RawBucketResultNode bucketId = (RawBucketResultNode)res;
+ return new RawBucketId(bucketId.getFrom(), bucketId.getTo());
+ } else {
+ throw new UnsupportedOperationException(res.getClass().getName());
+ }
+ }
+
+ Object newResult(ExpressionNode execResult, int tag) {
+ if (execResult instanceof AverageAggregationResult) {
+ return ((AverageAggregationResult)execResult).getAverage().getNumber();
+ } else if (execResult instanceof CountAggregationResult) {
+ return ((CountAggregationResult)execResult).getCount();
+ } else if (execResult instanceof ExpressionCountAggregationResult) {
+ long count = ((ExpressionCountAggregationResult)execResult).getEstimatedUniqueCount();
+ return correctExpressionCountEstimate(count, tag);
+ } else if (execResult instanceof MaxAggregationResult) {
+ return ((MaxAggregationResult)execResult).getMax().getValue();
+ } else if (execResult instanceof MinAggregationResult) {
+ return ((MinAggregationResult)execResult).getMin().getValue();
+ } else if (execResult instanceof SumAggregationResult) {
+ return ((SumAggregationResult)execResult).getSum().getValue();
+ } else if (execResult instanceof XorAggregationResult) {
+ return ((XorAggregationResult)execResult).getXor();
+ } else {
+ throw new UnsupportedOperationException(execResult.getClass().getName());
+ }
+ }
+
+ private long correctExpressionCountEstimate(long count, int tag) {
+ int actualGroupCount = group.getChildren().size();
+ // Use actual group count if estimate differ. If max is present, only use actual group count if less than max.
+ // NOTE: If the actual group count is 0, estimate is also 0.
+ if (actualGroupCount > 0 && count != actualGroupCount) {
+ if (transform.getMax(tag + 1) == 0 || transform.getMax(tag + 1) > actualGroupCount) {
+ return actualGroupCount;
+ }
+ }
+ return count;
+ }
+
+
+ HitList newHitList(int listIdx, int tag, HitsAggregationResult execResult) {
+ HitList hitList = new HitList(transform.getLabel(tag));
+ List<Hit> hits = execResult.getHits();
+ PageInfo page = new PageInfo(resultId.newChildId(listIdx), tag, stable, hits.size());
+ for (int i = page.firstEntry; i < page.lastEntry; ++i) {
+ hitList.add(hitConverter.toSearchHit(execResult.getSummaryClass(), hits.get(i)));
+ }
+ page.putContinuations(hitList.continuations());
+ return hitList;
+ }
+ }
+
+ private class GroupListBuilder {
+
+ final Map<ResultNode, GroupBuilder> childResultGroups = new HashMap<>();
+ final List<GroupBuilder> childGroups = new ArrayList<>();
+ final ResultId resultId;
+ final int tag;
+ final boolean stable;
+ final boolean stableChildren;
+ final boolean ranked;
+
+ GroupListBuilder(ResultId resultId, int tag, boolean stable, boolean ranked) {
+ this.resultId = resultId;
+ this.tag = tag;
+ this.stable = stable;
+ this.stableChildren = stable && transform.isStable(resultId);
+ this.ranked = ranked;
+ }
+
+ GroupList build() {
+ PageInfo page = new PageInfo(resultId, tag, stable, childGroups.size());
+ GroupList groupList = new GroupList(transform.getLabel(tag));
+ for (int i = page.firstEntry; i < page.lastEntry; ++i) {
+ GroupBuilder child = childGroups.get(i);
+ groupList.add(child.build(ranked ? child.group.getRank() :
+ (double)(page.lastEntry - i) / (page.lastEntry - page.firstEntry)));
+ }
+ page.putContinuations(groupList.continuations());
+ return groupList;
+ }
+
+ void addGroup(com.yahoo.searchlib.aggregation.Group execGroup) {
+ GroupBuilder groupBuilder = getOrCreateGroup(execGroup);
+ if (!execGroup.getChildren().isEmpty()) {
+ boolean ranked = execGroup.getChildren().get(0).isRankedByRelevance();
+ execGroup.sortChildrenByRank();
+ for (com.yahoo.searchlib.aggregation.Group childGroup : execGroup.getChildren()) {
+ GroupListBuilder childList = groupBuilder.getOrCreateChildList(childGroup.getTag(), ranked);
+ childList.addGroup(childGroup);
+ }
+ }
+ }
+
+ GroupBuilder getOrCreateGroup(com.yahoo.searchlib.aggregation.Group execGroup) {
+ ResultNode res = execGroup.getId();
+ GroupBuilder ret = childResultGroups.get(res);
+ if (ret != null) {
+ ret.merge(execGroup);
+ } else {
+ ret = new GroupBuilder(resultId.newChildId(childResultGroups.size()), execGroup, stableChildren);
+ childResultGroups.put(res, ret);
+ childGroups.add(ret);
+ }
+ return ret;
+ }
+ }
+
+ private class PageInfo {
+
+ final ResultId resultId;
+ final int tag;
+ final int max;
+ final int numEntries;
+ final int firstEntry;
+ final int lastEntry;
+
+ PageInfo(ResultId resultId, int tag, boolean stable, int numEntries) {
+ this.resultId = resultId;
+ this.tag = tag;
+ this.numEntries = numEntries;
+ max = transform.getMax(tag);
+ if (max > 0) {
+ firstEntry = stable ? transform.getOffset(resultId) : 0;
+ lastEntry = Math.min(numEntries, firstEntry + max);
+ } else {
+ firstEntry = 0;
+ lastEntry = numEntries;
+ }
+ }
+
+ void putContinuations(Map<String, Continuation> out) {
+ if (max > 0) {
+ if (firstEntry > 0) {
+ continuation.add(new OffsetContinuation(resultId, tag, firstEntry, 0));
+
+ int prevPage = Math.max(0, Math.min(firstEntry, lastEntry) - max);
+ out.put(Continuation.PREV_PAGE, new OffsetContinuation(resultId, tag, prevPage,
+ OffsetContinuation.FLAG_UNSTABLE));
+ }
+ if (lastEntry < numEntries) {
+ out.put(Continuation.NEXT_PAGE, new OffsetContinuation(resultId, tag, lastEntry,
+ OffsetContinuation.FLAG_UNSTABLE));
+ }
+ }
+ }
+ }
+
+ /**
+ * Defines a helper interface to convert Vespa style grouping hits into corresponding instances of {@link Hit}. It
+ * is an interface to simplify testing.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+ public interface HitConverter {
+
+ public com.yahoo.search.result.Hit toSearchHit(String summaryClass, com.yahoo.searchlib.aggregation.Hit hit);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultId.java b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultId.java
new file mode 100644
index 00000000000..21026ac7e92
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/grouping/vespa/ResultId.java
@@ -0,0 +1,71 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.grouping.vespa;
+
+import java.util.Arrays;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+class ResultId {
+
+ private final int[] indexes;
+ private final int hashCode;
+
+ private ResultId(int[] indexes) {
+ this.indexes = indexes;
+ this.hashCode = Arrays.hashCode(indexes);
+ }
+
+ public boolean startsWith(int... prefix) {
+ if (prefix.length > indexes.length) {
+ return false;
+ }
+ for (int i = 0; i < prefix.length; ++i) {
+ if (prefix[i] != indexes[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public ResultId newChildId(int childIdx) {
+ int[] arr = Arrays.copyOf(indexes, indexes.length + 1);
+ arr[indexes.length] = childIdx;
+ return new ResultId(arr);
+ }
+
+ @Override
+ public int hashCode() {
+ return hashCode;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof ResultId && Arrays.equals(indexes, ((ResultId)obj).indexes);
+ }
+
+ @Override
+ public String toString() {
+ return Arrays.toString(indexes);
+ }
+
+ public void encode(IntegerEncoder out) {
+ out.append(indexes.length);
+ for (int i : indexes) {
+ out.append(i);
+ }
+ }
+
+ public static ResultId decode(IntegerDecoder in) {
+ int len = in.next();
+ int[] arr = new int[len];
+ for (int i = 0; i < len; ++i) {
+ arr[i] = in.next();
+ }
+ return new ResultId(arr);
+ }
+
+ public static ResultId valueOf(int... indexes) {
+ return new ResultId(Arrays.copyOf(indexes, indexes.length));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/handler/HttpSearchResponse.java b/container-search/src/main/java/com/yahoo/search/handler/HttpSearchResponse.java
new file mode 100644
index 00000000000..f844b5dd940
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/handler/HttpSearchResponse.java
@@ -0,0 +1,173 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.handler;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.yahoo.collections.ListMap;
+import com.yahoo.container.jdisc.ExtendedResponse;
+import com.yahoo.container.handler.Coverage;
+import com.yahoo.container.handler.Timing;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.logging.AccessLogEntry;
+import com.yahoo.container.logging.HitCounts;
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.processing.execution.Execution.Trace.LogValue;
+import com.yahoo.processing.rendering.AsynchronousSectionedRenderer;
+import com.yahoo.processing.rendering.Renderer;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.query.context.QueryContext;
+
+/**
+ * Wrap the result of a query as an HTTP response.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class HttpSearchResponse extends ExtendedResponse {
+
+ private final Result result;
+ private final Query query;
+ private final Renderer<Result> rendererCopy;
+ private final Timing timing;
+ private final HitCounts hitCounts;
+
+ public HttpSearchResponse(int status, Result result, Query query, Renderer renderer) {
+ super(status);
+ this.query = query;
+ this.result = result;
+ this.rendererCopy = renderer;
+
+ this.timing = SearchResponse.createTiming(query, result);
+ this.hitCounts = SearchResponse.createHitCounts(query, result);
+ populateHeaders(headers(), result.getHeaders(false));
+ }
+
+ /**
+ * Copy custom HTTP headers from the search result over to the HTTP
+ * response.
+ *
+ * @param outputHeaders
+ * the headers which will be sent to a client
+ * @param searchHeaders
+ * the headers from the search result, or null
+ */
+ private static void populateHeaders(HeaderFields outputHeaders,
+ ListMap<String, String> searchHeaders) {
+ if (searchHeaders == null) {
+ return;
+ }
+ for (Map.Entry<String, List<String>> header : searchHeaders.entrySet()) {
+ for (String value : header.getValue()) {
+ outputHeaders.add(header.getKey(), value);
+ }
+ }
+ }
+
+ public ListenableFuture<Boolean> waitableRender(OutputStream stream) throws IOException {
+ return waitableRender(result, query, rendererCopy, stream);
+ }
+
+ public static ListenableFuture<Boolean> waitableRender(Result result,
+ Query query,
+ Renderer<Result> renderer,
+ OutputStream stream) throws IOException {
+ SearchResponse.trimHits(result);
+ SearchResponse.removeEmptySummaryFeatureFields(result);
+ return renderer.render(stream, result, query.getModel().getExecution(), query);
+
+ }
+
+ @Override
+ public void render(OutputStream output, ContentChannel networkChannel, CompletionHandler handler) throws IOException {
+ if (rendererCopy instanceof AsynchronousSectionedRenderer) {
+ AsynchronousSectionedRenderer<Result> renderer = (AsynchronousSectionedRenderer<Result>) rendererCopy;
+ renderer.setNetworkWiring(networkChannel, handler);
+ }
+ try {
+ try {
+ waitableRender(output);
+ } finally {
+ if (!(rendererCopy instanceof AsynchronousSectionedRenderer)) {
+ output.flush();
+ }
+ }
+ } finally {
+ if (networkChannel != null && !(rendererCopy instanceof AsynchronousSectionedRenderer)) {
+ networkChannel.close(handler);
+ }
+ }
+ }
+
+ @Override
+ public void populateAccessLogEntry(final AccessLogEntry accessLogEntry) {
+ super.populateAccessLogEntry(accessLogEntry);
+ populateAccessLogEntry(accessLogEntry, getHitCounts());
+ }
+
+ /* package-private */
+ static void populateAccessLogEntry(AccessLogEntry jdiscRequestAccessLogEntry, HitCounts hitCounts) {
+ // This entry will be logged at Jetty level. Here we just populate with tidbits from this context.
+
+ jdiscRequestAccessLogEntry.setHitCounts(hitCounts);
+ }
+
+ @Override
+ public String getParsedQuery() {
+ return query.toString();
+ }
+
+ @Override
+ public Timing getTiming() {
+ return timing;
+ }
+
+ @Override
+ public Coverage getCoverage() {
+ return result.getCoverage(false);
+ }
+
+ @Override
+ public HitCounts getHitCounts() {
+ return hitCounts;
+ }
+
+ /**
+ * Returns MIME type of this response
+ */
+ @Override
+ public String getContentType() {
+ return rendererCopy.getMimeType();
+ }
+
+ /**
+ * Returns expected character encoding of this response
+ */
+ @Override
+ public String getCharacterEncoding() {
+ String encoding = result.getQuery().getModel().getEncoding();
+ return (encoding != null) ? encoding : rendererCopy.getEncoding();
+ }
+
+ /** Returns the query wrapped by this */
+ public Query getQuery() { return query; }
+
+ /** Returns the result wrapped by this */
+ public Result getResult() { return result; }
+
+ @Override
+ public Iterable<LogValue> getLogValues() {
+ QueryContext context = query.getContext(false);
+ return context == null
+ ? Collections::emptyIterator
+ : context::logValueIterator;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/handler/SearchHandler.java b/container-search/src/main/java/com/yahoo/search/handler/SearchHandler.java
new file mode 100644
index 00000000000..c431fdac638
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/handler/SearchHandler.java
@@ -0,0 +1,532 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.handler;
+
+import com.google.inject.Inject;
+import com.yahoo.collections.Tuple2;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.component.chain.ChainsConfigurer;
+import com.yahoo.component.chain.model.ChainsModel;
+import com.yahoo.component.chain.model.ChainsModelBuilder;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.container.Container;
+import com.yahoo.container.QrSearchersConfig;
+import com.yahoo.container.core.ChainsConfig;
+import com.yahoo.container.core.QrTemplatesConfig;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.jdisc.VespaHeaders;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.container.protect.FreezeDetector;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.language.Linguistics;
+import com.yahoo.log.LogLevel;
+import com.yahoo.net.UriTools;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.IndexModel;
+import com.yahoo.prelude.VespaSVersionRetriever;
+import com.yahoo.prelude.query.QueryException;
+import com.yahoo.prelude.query.parser.ParseException;
+import com.yahoo.prelude.query.parser.SpecialTokenRegistry;
+import com.yahoo.processing.rendering.Renderer;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.config.IndexInfoConfig;
+import com.yahoo.search.debug.DebugRpcAdaptor;
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfile;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.query.profile.config.QueryProfileConfigurer;
+import com.yahoo.search.query.profile.config.QueryProfilesConfig;
+import com.yahoo.search.query.properties.DefaultProperties;
+import com.yahoo.search.rendering.RendererRegistry;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.SearchChainRegistry;
+import com.yahoo.search.statistics.ElapsedTime;
+import com.yahoo.statistics.Callback;
+import com.yahoo.statistics.Handle;
+import com.yahoo.statistics.Statistics;
+import com.yahoo.statistics.Value;
+import com.yahoo.vespa.configdefinition.SpecialtokensConfig;
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Handles search request.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class SearchHandler extends LoggingRequestHandler {
+
+ private final AtomicInteger requestsInFlight = new AtomicInteger(0);
+
+ // max number of threads for the executor for this handler
+ private final int maxThreads;
+
+ private static final CompoundName DETAILED_TIMING_LOGGING = new CompoundName("trace.timingDetails");
+
+ /** Event name for number of connections to the search subsystem */
+ private static final String SEARCH_CONNECTIONS = "search_connections";
+
+ private static Logger log = Logger.getLogger(SearchHandler.class.getName());
+
+ private Value searchConnections;
+
+ private final SearchChainRegistry searchChainRegistry;
+
+ private final RendererRegistry rendererRegistry;
+
+ private final IndexFacts indexFacts;
+
+ private final SpecialTokenRegistry specialTokens;
+
+ public static final String defaultSearchChainName = "default";
+ private static final String fallbackSearchChain = "vespa";
+ private static final CompoundName FORCE_TIMESTAMPS = new CompoundName("trace.timestamps");;
+
+ // This is a hack to add the RPC adaptors for search only once
+ // TODO: Figure out the correct life cycle and init of RPC adaptors
+ static {
+ Container c = Container.get();
+ c.addOptionalRpcAdaptor(new DebugRpcAdaptor());
+ }
+
+ private final Linguistics linguistics;
+
+ private final CompiledQueryProfileRegistry queryProfileRegistry;
+
+ private final class MeanConnections implements Callback {
+ @Override
+ public void run(final Handle h, final boolean firstTime) {
+ if (firstTime) {
+ metric.set(SEARCH_CONNECTIONS, 0.0d, null);
+ return;
+ }
+ Value v = (Value) h;
+ metric.set(SEARCH_CONNECTIONS, v.getMean(), null);
+ }
+ }
+
+ @Inject
+ public SearchHandler(
+ final ChainsConfig chainsConfig,
+ final IndexInfoConfig indexInfo,
+ final QrSearchersConfig clusters,
+ final SpecialtokensConfig specialtokens,
+ final Statistics statistics,
+ final Linguistics linguistics,
+ final Metric metric,
+ final ComponentRegistry<Renderer> renderers,
+ final Executor executor,
+ final AccessLog accessLog,
+ final QueryProfilesConfig queryProfileConfig,
+ final ComponentRegistry<Searcher> searchers) {
+ super(executor, accessLog, metric, true);
+ log.log(LogLevel.DEBUG, "SearchHandler.init " + System.identityHashCode(this));
+ searchChainRegistry = new SearchChainRegistry(searchers);
+ setupSearchChainRegistry(searchers, chainsConfig);
+ indexFacts = new IndexFacts(new IndexModel(indexInfo, clusters));
+ indexFacts.freeze();
+ specialTokens = new SpecialTokenRegistry(specialtokens);
+ rendererRegistry = new RendererRegistry(renderers.allComponents());
+ QueryProfileRegistry queryProfileRegistry = QueryProfileConfigurer.createFromConfig(queryProfileConfig);
+ this.queryProfileRegistry = queryProfileRegistry.compile();
+
+ this.linguistics = linguistics;
+ this.maxThreads = examineExecutor(executor);
+
+ searchConnections = new Value(SEARCH_CONNECTIONS, statistics,
+ new Value.Parameters().setLogRaw(true).setLogMax(true)
+ .setLogMean(true).setLogMin(true)
+ .setNameExtension(true)
+ .setCallback(new MeanConnections()));
+ }
+
+ /** @deprecated use the constructor without deprecated parameters */
+ @Deprecated
+ public SearchHandler(
+ final ChainsConfig chainsConfig,
+ final IndexInfoConfig indexInfo,
+ final QrSearchersConfig clusters,
+ final SpecialtokensConfig specialTokens,
+ final QrTemplatesConfig ignored,
+ final FreezeDetector ignored2,
+ final Statistics statistics,
+ final Linguistics linguistics,
+ final Metric metric,
+ final ComponentRegistry<Renderer> renderers,
+ final Executor executor,
+ final AccessLog accessLog,
+ final QueryProfilesConfig queryProfileConfig,
+ final ComponentRegistry<Searcher> searchers) {
+ this(chainsConfig, indexInfo, clusters, specialTokens, statistics, linguistics, metric, renderers,
+ executor, accessLog, queryProfileConfig, searchers);
+ }
+
+ private void setupSearchChainRegistry(final ComponentRegistry<Searcher> searchers,
+ final ChainsConfig chainsConfig) {
+ final ChainsModel chainsModel = ChainsModelBuilder.buildFromConfig(chainsConfig);
+ ChainsConfigurer.prepareChainRegistry(searchChainRegistry, chainsModel, searchers);
+ searchChainRegistry.freeze();
+ }
+
+ private static int examineExecutor(Executor executor) {
+ if (executor instanceof ThreadPoolExecutor) {
+ return ((ThreadPoolExecutor) executor).getMaximumPoolSize();
+ }
+ return Integer.MAX_VALUE; // assume unbound
+ }
+
+ @Override
+ public final HttpResponse handle(com.yahoo.container.jdisc.HttpRequest request) {
+ requestsInFlight.incrementAndGet();
+ try {
+ try {
+ return handleBody(request);
+ } catch (final QueryException e) {
+ return (e.getCause() instanceof IllegalArgumentException)
+ ? invalidParameterResponse(request, e)
+ : illegalQueryResponse(request, e);
+ } catch (final RuntimeException e) { // Make sure we generate a valid
+ // XML response even on unexpected
+ // errors
+ log.log(Level.WARNING, "Failed handling " + request, e);
+ return internalServerErrorResponse(request, e);
+ }
+ } finally {
+ requestsInFlight.decrementAndGet();
+ }
+ }
+
+ private int getHttpResponseStatus(com.yahoo.container.jdisc.HttpRequest httpRequest, Result result) {
+ boolean benchmarkOutput = VespaHeaders.benchmarkOutput(httpRequest);
+ if (benchmarkOutput) {
+ return VespaHeaders.getEagerErrorStatus(result.hits().getError(),
+ SearchResponse.getErrorIterator(result.hits().getErrorHit()));
+ } else {
+ return VespaHeaders.getStatus(SearchResponse.isSuccess(result),
+ result.hits().getError(),
+ SearchResponse.getErrorIterator(result.hits().getErrorHit()));
+ }
+
+ }
+
+ @SuppressWarnings("unchecked")
+ private HttpResponse errorResponse(HttpRequest request, ErrorMessage errorMessage) {
+ Query query = new Query();
+ Result result = new Result(query, errorMessage);
+ Renderer renderer = getRendererCopy(ComponentSpecification.fromString(request.getProperty("format")));
+
+ result.getTemplating().setRenderer(renderer); // Pre-Vespa 6 Result.getEncoding() expects this TODO: Remove
+
+ return new HttpSearchResponse(getHttpResponseStatus(request, result), result, query, renderer);
+ }
+
+ private HttpResponse invalidParameterResponse(HttpRequest request, RuntimeException e) {
+ return errorResponse(request, ErrorMessage.createInvalidQueryParameter(Exceptions.toMessageString(e)));
+ }
+
+ private HttpResponse illegalQueryResponse(HttpRequest request, RuntimeException e) {
+ return errorResponse(request, ErrorMessage.createIllegalQuery(Exceptions.toMessageString(e)));
+ }
+
+ private HttpResponse internalServerErrorResponse(HttpRequest request, RuntimeException e) {
+ return errorResponse(request, ErrorMessage.createInternalServerError(Exceptions.toMessageString(e)));
+ }
+
+ private HttpSearchResponse handleBody(HttpRequest request) {
+ // Find query profile
+ String queryProfileName = request.getProperty("queryProfile");
+ CompiledQueryProfile queryProfile = queryProfileRegistry.findQueryProfile(queryProfileName);
+ boolean benchmarkOutput = VespaHeaders.benchmarkOutput(request);
+
+ // Create query
+ Query query = new Query(request, queryProfile);
+
+ boolean benchmarkCoverage = VespaHeaders.benchmarkCoverage(benchmarkOutput, request.getJDiscRequest().headers());
+ if (benchmarkCoverage) {
+ query.getPresentation().setReportCoverage(true);
+ }
+
+ // Find and execute search chain if we have a valid query
+ String invalidReason = query.validate();
+ Chain<Searcher> searchChain = null;
+ String searchChainName = null;
+ if (invalidReason == null) {
+ Tuple2<String, Chain<Searcher>> nameAndChain = resolveChain(query.properties().getString(Query.SEARCH_CHAIN));
+ searchChainName = nameAndChain.first;
+ searchChain = nameAndChain.second;
+ }
+
+ // Create the result
+ Result result;
+ if (invalidReason != null) {
+ result = new Result(query, ErrorMessage.createIllegalQuery(invalidReason));
+ } else if (queryProfile == null && queryProfileName != null) {
+ result = new Result(
+ query,
+ ErrorMessage.createIllegalQuery("Could not resolve query profile '" + queryProfileName + "'"));
+ } else if (searchChain == null) {
+ result = new Result(
+ query,
+ ErrorMessage.createInvalidQueryParameter("No search chain named '" + searchChainName + "' was found"));
+ } else {
+ String pathAndQuery = UriTools.rawRequest(request.getUri());
+ result = search(pathAndQuery, query, searchChain, searchChainRegistry);
+ }
+
+ Renderer renderer;
+ if (result.getTemplating().usesDefaultTemplate()) {
+ renderer = toRendererCopy(query.getPresentation().getRenderer());
+ result.getTemplating().setRenderer(renderer); // pre-Vespa 6 Result.getEncoding() expects this to be set. TODO: Remove
+ }
+ else { // somebody explicitly assigned a old style template
+ renderer = perRenderingCopy(result.getTemplating().getRenderer());
+ }
+
+ // Transform result to response
+ HttpSearchResponse response = new HttpSearchResponse(getHttpResponseStatus(request, result),
+ result, query, renderer);
+ if (benchmarkOutput) {
+ VespaHeaders.benchmarkOutput(response.headers(), benchmarkCoverage, response.getTiming(),
+ response.getHitCounts(), getErrors(result), response.getCoverage());
+ }
+
+ return response;
+ }
+
+ private static int getErrors(Result result) {
+ return result.hits().getErrorHit() == null ? 0 : 1;
+ }
+
+ @NonNull
+ private Renderer<Result> toRendererCopy(ComponentSpecification format) {
+ Renderer<Result> renderer = rendererRegistry.getRenderer(format);
+ renderer = perRenderingCopy(renderer);
+ return renderer;
+ }
+
+ private Tuple2<String, Chain<Searcher>> resolveChain(String explicitChainName) {
+ String chainName = explicitChainName;
+ if (chainName == null) {
+ chainName = defaultSearchChainName;
+ }
+
+ Chain<Searcher> searchChain = searchChainRegistry.getChain(chainName);
+ if (searchChain == null && explicitChainName == null) { // explicit
+ // search chain
+ // not found
+ // should cause
+ // error
+ chainName = fallbackSearchChain;
+ searchChain = searchChainRegistry.getChain(chainName);
+ }
+ return new Tuple2<>(chainName, searchChain);
+ }
+
+ /** Used from container SDK, for internal use only */
+ public Result searchAndFill(Query query, Chain<? extends Searcher> searchChain, SearchChainRegistry registry) {
+ Result errorResult = validateQuery(query);
+ if (errorResult != null) return errorResult;
+
+ Renderer<Result> renderer = rendererRegistry.getRenderer(query.getPresentation().getRenderer());
+
+ // docsumClass null means "unset", so we set it (it might be null
+ // here too in which case it will still be "unset" after we
+ // set it :-)
+ if (query.getPresentation().getSummary() == null && renderer instanceof com.yahoo.search.rendering.Renderer)
+ query.getPresentation().setSummary(((com.yahoo.search.rendering.Renderer) renderer).getDefaultSummaryClass());
+
+ Execution execution = new Execution(searchChain,
+ new Execution.Context(registry, indexFacts, specialTokens, rendererRegistry, linguistics));
+ query.getModel().setExecution(execution);
+ query.getModel().traceLanguage();
+ execution.trace().setForceTimestamps(query.properties().getBoolean(FORCE_TIMESTAMPS, false));
+ if (query.properties().getBoolean(DETAILED_TIMING_LOGGING, false)) {
+ // check and set (instead of set directly) to avoid overwriting stuff from prepareForBreakdownAnalysis()
+ execution.context().setDetailedDiagnostics(true);
+ }
+ Result result = execution.search(query);
+
+ if (result.getTemplating() == null)
+ result.getTemplating().setRenderer(renderer);
+
+ ensureQuerySet(result, query);
+ execution.fill(result, result.getQuery().getPresentation().getSummary());
+
+ traceExecutionTimes(query, result);
+ traceVespaSVersion(query);
+ traceRequestAttributes(query);
+ return result;
+ }
+
+ private void traceRequestAttributes(Query query) {
+ int miminumTraceLevel = 7;
+ if (query.getTraceLevel() >= 7) {
+ query.trace("Request attributes: " + query.getHttpRequest().getJDiscRequest().context(), miminumTraceLevel);
+ }
+ }
+
+ /**
+ * For internal use only
+ */
+ public Renderer<Result> getRendererCopy(ComponentSpecification spec) { // TODO: Deprecate this
+ Renderer<Result> renderer = rendererRegistry.getRenderer(spec);
+ return perRenderingCopy(renderer);
+ }
+
+ @NonNull
+ private Renderer<Result> perRenderingCopy(Renderer<Result> renderer) {
+ Renderer<Result> copy = renderer.clone();
+ copy.init();
+ return copy;
+ }
+
+ private void ensureQuerySet(Result result, Query fallbackQuery) {
+ Query query = result.getQuery();
+ if (query == null) {
+ result.setQuery(fallbackQuery);
+ }
+ }
+
+ private Result search(String request, Query query, Chain<Searcher> searchChain, SearchChainRegistry registry) {
+ if (query.getTraceLevel() >= 2) {
+ query.trace("Invoking " + searchChain, false, 2);
+ }
+
+ if (searchConnections != null) {
+ connectionStatistics();
+ } else {
+ log.log(LogLevel.WARNING,
+ "searchConnections is a null reference, probably a known race condition during startup.",
+ new IllegalStateException("searchConnections reference is null."));
+ }
+ try {
+ return searchAndFill(query, searchChain, registry);
+ } catch (ParseException e) {
+ ErrorMessage error = ErrorMessage.createIllegalQuery("Could not parse query [" + request + "]: "
+ + Exceptions.toMessageString(e));
+ log.log(LogLevel.DEBUG, () -> error.getDetailedMessage());
+ return new Result(query, error);
+ } catch (IllegalArgumentException e) {
+ ErrorMessage error = ErrorMessage.createBadRequest("Invalid search request [" + request + "]: "
+ + Exceptions.toMessageString(e));
+ log.log(LogLevel.DEBUG, () -> error.getDetailedMessage());
+ return new Result(query, error);
+ } catch (LinkageError e) {
+ // Should have been an Exception in an OSGi world - typical bundle dependency issue problem
+ ErrorMessage error = ErrorMessage.createErrorInPluginSearcher(
+ "Error executing " + searchChain + "]: " + Exceptions.toMessageString(e), e);
+ log(request, query, e);
+ return new Result(query, error);
+ } catch (StackOverflowError e) { // Also recoverable
+ ErrorMessage error = ErrorMessage.createErrorInPluginSearcher(
+ "Error executing " + searchChain + "]: " + Exceptions.toMessageString(e), e);
+ log(request, query, e);
+ return new Result(query, error);
+ } catch (Exception e) {
+ Result result = new Result(query);
+ log(request, query, e);
+ result.hits().setError(
+ ErrorMessage.createUnspecifiedError("Failed searching: " + Exceptions.toMessageString(e), e));
+ return result;
+ }
+ }
+
+ private void connectionStatistics() {
+ int connections = requestsInFlight.intValue();
+ searchConnections.put(connections);
+ if (maxThreads > 3) {
+ // cast to long to avoid overflows if maxThreads is at no
+ // log value (maxint)
+ final long maxThreadsAsLong = maxThreads;
+ final long connectionsAsLong = connections;
+ // only log when exactly crossing the limit to avoid
+ // spamming the log
+ if (connectionsAsLong < maxThreadsAsLong * 9L / 10L) {
+ // NOP
+ } else if (connectionsAsLong == maxThreadsAsLong * 9L / 10L) {
+ log.log(Level.WARNING, threadConsumptionMessage(connections, maxThreads, "90"));
+ } else if (connectionsAsLong == maxThreadsAsLong * 95L / 100L) {
+ log.log(Level.WARNING, threadConsumptionMessage(connections, maxThreads, "95"));
+ } else if (connectionsAsLong == maxThreadsAsLong) {
+ log.log(Level.WARNING, threadConsumptionMessage(connections, maxThreads, "100"));
+ }
+ }
+ }
+
+ private String threadConsumptionMessage(int connections, int maxThreads, String percentage) {
+ return percentage + "% of possible search connections (" + connections +
+ " of maximum " + maxThreads + ") currently active.";
+ }
+
+ private void log(String request, Query query, Throwable e) {
+ // Attempted workaround for missing stack traces
+ if (e.getStackTrace().length == 0) {
+ log.log(LogLevel.ERROR,
+ "Failed executing " + query.toDetailString() + " [" + request
+ + "], received exception with no context", e);
+ } else {
+ log.log(LogLevel.ERROR,
+ "Failed executing " + query.toDetailString() + " [" + request + "]", e);
+ }
+ }
+
+ private Result validateQuery(Query query) {
+ if (query.getHttpRequest().getProperty(DefaultProperties.MAX_HITS.toString()) != null)
+ throw new RuntimeException(DefaultProperties.MAX_HITS + " must be specified in a query profile.");
+
+ if (query.getHttpRequest().getProperty(DefaultProperties.MAX_OFFSET.toString()) != null)
+ throw new RuntimeException(DefaultProperties.MAX_OFFSET + " must be specified in a query profile.");
+
+ int maxHits = query.properties().getInteger(DefaultProperties.MAX_HITS);
+ int maxOffset = query.properties().getInteger(DefaultProperties.MAX_OFFSET);
+
+ if (query.getHits() > maxHits) {
+ return new Result(query, ErrorMessage.createIllegalQuery(query.getHits() +
+ " hits requested, configured limit: " + maxHits + "."));
+
+ } else if (query.getOffset() > maxOffset) {
+ return new Result(query,
+ ErrorMessage.createIllegalQuery("Offset of " + query.getOffset() +
+ " requested, configured limit: " + maxOffset + "."));
+ }
+ return null;
+ }
+
+ private void traceExecutionTimes(Query query, Result result) {
+ if (query.getTraceLevel() < 3) return;
+
+ ElapsedTime elapsedTime = result.getElapsedTime();
+ long now = System.currentTimeMillis();
+ if (elapsedTime.firstFill() != 0) {
+ query.trace("Query time " + query + ": "
+ + (elapsedTime.firstFill() - elapsedTime.first()) + " ms", false, 3);
+
+ query.trace("Summary fetch time " + query + ": "
+ + (now - elapsedTime.firstFill()) + " ms", false, 3);
+ } else {
+ query.trace("Total search time " + query + ": "
+ + (now - elapsedTime.first()) + " ms", false, 3);
+ }
+ }
+
+ private void traceVespaSVersion(Query query) {
+ query.trace("Vespa version: " + VespaSVersionRetriever.getVersion(), false, 4);
+ }
+
+ public SearchChainRegistry getSearchChainRegistry() {
+ return searchChainRegistry;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/handler/SearchResponse.java b/container-search/src/main/java/com/yahoo/search/handler/SearchResponse.java
new file mode 100644
index 00000000000..b0460ee6597
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/handler/SearchResponse.java
@@ -0,0 +1,68 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.handler;
+
+import com.yahoo.container.handler.Timing;
+import com.yahoo.container.logging.HitCounts;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.result.ErrorHit;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+
+/**
+ * Some leftover static methods.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class SearchResponse {
+
+ // Remove (the empty) summary feature field if not requested.
+ static void removeEmptySummaryFeatureFields(Result result) {
+ // TODO: Move to some searcher in Vespa backend search chains
+ if (!result.hits().getQuery().getRanking().getListFeatures())
+ for (Iterator<Hit> i = result.hits().unorderedIterator(); i.hasNext();)
+ i.next().removeField(Hit.RANKFEATURES_FIELD);
+ }
+
+ static void trimHits(Result result) {
+ if (result.getConcreteHitCount() > result.hits().getQuery().getHits()) {
+ result.hits().trim(0, result.hits().getQuery().getHits());
+ }
+ }
+
+ static Iterator<? extends ErrorMessage> getErrorIterator(ErrorHit h) {
+ if (h == null) {
+ return new ArrayList<ErrorMessage>(0).iterator();
+ } else {
+ return h.errorIterator();
+ }
+ }
+
+ static boolean isSuccess(Result r) {
+ if (r.hits().getErrorHit()==null) return true;
+ for (Hit hit : r.hits())
+ if ( ! hit.isMeta()) return true; // contains data : success
+ return false;
+ }
+
+ @SuppressWarnings("deprecation")
+ public static Timing createTiming(Query query, Result result) {
+ return new Timing(result.getElapsedTime().firstFill(),
+ 0,
+ result.getElapsedTime().first(), query.getTimeout());
+ }
+
+ public static HitCounts createHitCounts(Query query, Result result) {
+ return new HitCounts(result.getHitCount(),
+ result.getConcreteHitCount(),
+ result.getTotalHitCount(),
+ query.getHits(),
+ query.getOffset());
+ }
+
+}
+
+
diff --git a/container-search/src/main/java/com/yahoo/search/handler/package-info.java b/container-search/src/main/java/com/yahoo/search/handler/package-info.java
new file mode 100644
index 00000000000..fa35495e3f8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/handler/package-info.java
@@ -0,0 +1,11 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * The search handler, which handles search request to the Container by translating the Request into a Query, invoking the
+ * chosen Search Chain to get a Result, which it translates to a Response which is returned to the Container.
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.handler;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/Intent.java b/container-search/src/main/java/com/yahoo/search/intent/model/Intent.java
new file mode 100644
index 00000000000..f9d97e057d1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/Intent.java
@@ -0,0 +1,39 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+/**
+ * A representation of an intent behind a query. Intents have no structure but are just id's of a
+ * set which is predefined in the application.
+ * <p>
+ * Intents are Value Objects.
+ * <p>
+ * Intent ids should be human readable, start with lower case and use camel casing
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Intent {
+
+ private String id;
+
+ public static final Intent Default=new Intent("default");
+
+ /** Creates an intent from a string id */
+ public Intent(String id) {
+ this.id=id;
+ }
+
+ /** Returns the id of this intent, never null */
+ public String getId() { return id; }
+
+ public @Override int hashCode() { return id.hashCode(); }
+
+ public @Override boolean equals(Object other) {
+ if (other==this) return true;
+ if ( ! (other instanceof Intent)) return false;
+ return this.id.equals(((Intent)other).id);
+ }
+
+ /** Returns the id of this intent */
+ public @Override String toString() { return id; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/IntentModel.java b/container-search/src/main/java/com/yahoo/search/intent/model/IntentModel.java
new file mode 100644
index 00000000000..915c8fbd1d1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/IntentModel.java
@@ -0,0 +1,90 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+import com.yahoo.search.Query;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.text.interpretation.Interpretation;
+
+import java.util.*;
+
+/**
+ * This is the root node of an intent model.
+ * The intent model represents the intent analysis of a query.
+ * This is a probabilistic model - the query may have multiple interpretations with different probability.
+ * Each interpretation may have multiple
+ * possible intents, making this a tree.
+ *
+ * @author bratseth
+ */
+public class IntentModel extends ParentNode<InterpretationNode> {
+
+ /** The name of the property carrying the intent model string: intentModel */
+ public static final CompoundName intentModelStringName=new CompoundName("intentModel");
+ /** The name of the property carrying the intent model object: IntentModel */
+ public static final CompoundName intentModelObjectName=new CompoundName("IntentModel");
+
+ private static final InterpretationNodeComparator inodeComp = new InterpretationNodeComparator();
+
+ /** Creates an empty intent model */
+ public IntentModel() {
+ }
+
+ /** Creates an intent model from some interpretations */
+ public IntentModel(List<Interpretation> interpretations) {
+ for (Interpretation interpretation : interpretations)
+ children().add(new InterpretationNode(interpretation));
+ sortChildren();
+ }
+
+ /** Creates an intent model from some interpretations */
+ public IntentModel(Interpretation... interpretations) {
+ for (Interpretation interpretation : interpretations)
+ children().add(new InterpretationNode(interpretation));
+ sortChildren();
+ }
+
+ /** Sort interpretations by descending score order */
+ public void sortChildren() {
+ Collections.sort(children(), inodeComp);
+ }
+
+ /**
+ * Returns a flattened list of sources with a normalized appropriateness of each, sorted by
+ * decreasing appropriateness.
+ * This is obtained by summing the source appropriateness vectors of each intent node weighted
+ * by the owning intent and interpretation probabilities.
+ * Sources with a resulting probability of 0 is omitted in the returned list.
+ */
+ public List<SourceNode> getSources() {
+ Map<Source,SourceNode> sources=new HashMap<>();
+ addSources(1.0,sources);
+ List<SourceNode> sourceList=new ArrayList<>(sources.values());
+ Collections.sort(sourceList);
+ return sourceList;
+ }
+
+ /** Returns the names of the sources returned from {@link #getSources} for convenience */
+ public List<String> getSourceNames() {
+ List<String> sourceNames=new ArrayList<>();
+ for (SourceNode sourceNode : getSources())
+ sourceNames.add(sourceNode.getSource().getId());
+ return sourceNames;
+ }
+
+ /** Returns the intent model stored at property key "intentModel" in this query, or null if none */
+ public static IntentModel getFrom(Query query) {
+ return (IntentModel)query.properties().get(intentModelObjectName);
+ }
+
+ /** Stores this intent model at property key "intentModel" in this query */
+ public void setTo(Query query) {
+ query.properties().set(intentModelObjectName,this);
+ }
+
+ static class InterpretationNodeComparator implements Comparator<InterpretationNode> {
+ public int compare(InterpretationNode o1, InterpretationNode o2) {
+ double diff = o2.getScore()-o1.getScore();
+ return (diff>0) ? 1 : ( (diff<0)? -1:0 );
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/IntentNode.java b/container-search/src/main/java/com/yahoo/search/intent/model/IntentNode.java
new file mode 100644
index 00000000000..c77c937b760
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/IntentNode.java
@@ -0,0 +1,29 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+/**
+ * An intent in an intent model tree. The intent node score is the <i>probability</i> of this intent
+ * given the parent interpretation.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class IntentNode extends ParentNode<SourceNode> {
+
+ private Intent intent;
+
+ public IntentNode(Intent intent,double probabilityScore) {
+ super(probabilityScore);
+ this.intent=intent;
+ }
+
+ /** Returns the intent of this node, this is never null */
+ public Intent getIntent() { return intent; }
+
+ public void setIntent(Intent intent) { this.intent=intent; }
+
+ /** Returns intent:probability */
+ public @Override String toString() {
+ return intent + ":" + getScore();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/InterpretationNode.java b/container-search/src/main/java/com/yahoo/search/intent/model/InterpretationNode.java
new file mode 100644
index 00000000000..51e5d00c563
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/InterpretationNode.java
@@ -0,0 +1,45 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+import com.yahoo.text.interpretation.Interpretation;
+
+/**
+ * An interpretation which may have multiple intents. The score of this node is the probability of
+ * the wrapped interpretation.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class InterpretationNode extends ParentNode<IntentNode> {
+
+ private Interpretation interpretation;
+
+ public InterpretationNode(Interpretation interpretation) {
+ super(0); // Super score is not used
+ this.interpretation=interpretation;
+ children().add(new IntentNode(Intent.Default,1.0));
+ }
+
+ /** Returns this interpretation. This is never null. */
+ public Interpretation getInterpretation() { return interpretation; }
+
+ /** Sets this interpretation */
+ public void setInterpretation(Interpretation interpretation) {
+ this.interpretation=interpretation;
+ }
+
+ /** Returns the probability of the interpretation of this */
+ public @Override double getScore() {
+ return interpretation.getProbability();
+ }
+
+ /** Sets the probability of the interpretation of this */
+ public void setScore(double score) {
+ interpretation.setProbability(score);
+ }
+
+ /** Returns interpretations toString() */
+ public @Override String toString() {
+ return interpretation.toString();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/Node.java b/container-search/src/main/java/com/yahoo/search/intent/model/Node.java
new file mode 100644
index 00000000000..ecd3ec712bb
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/Node.java
@@ -0,0 +1,48 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+import java.util.Map;
+
+/**
+ * A node in the <a href="TODO">intent model tree</a>
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class Node implements Comparable<Node> {
+
+ /**
+ * The score, unless getScore/setScore is overridden which is the case with interpretations,
+ * so DO NOT ACCESS SCORE DIRECTLY, ALWAYS USE GET/SET
+ */
+ private double score;
+
+ public Node(double score) {
+ this.score=score;
+ }
+
+ /** Returns the normalized (0-1) score of this node */
+ public double getScore() { return score; }
+
+ /** Sets the normalized (0-1) score of this node */
+ public void setScore(double score) { this.score=score; }
+
+ /** Increases this score by an increment and returns the new score */
+ public double increaseScore(double increment) {
+ setScore(getScore()+increment);
+ return getScore();
+ }
+
+ public int compareTo(Node other) {
+ if (this.getScore()<other.getScore()) return 1;
+ if (this.getScore()>other.getScore()) return -1;
+ return 0;
+ }
+
+ /**
+ * Adds the sources at (and beneath) this node to the given
+ * sparsely represented source vector, weighted by the score of this node
+ * times the given weight from the parent path
+ */
+ abstract void addSources(double weight,Map<Source,SourceNode> sources);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/ParentNode.java b/container-search/src/main/java/com/yahoo/search/intent/model/ParentNode.java
new file mode 100644
index 00000000000..357060be93c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/ParentNode.java
@@ -0,0 +1,36 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A node which is not a leaf in the intent tree
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class ParentNode<T extends Node> extends Node {
+
+ private List<T> children=new ArrayList<>();
+
+ public ParentNode() {
+ super(1.0);
+ }
+
+ public ParentNode(double score) {
+ super(score);
+ }
+
+ /**
+ * This returns the children of this node in the intent tree.
+ * This is never null. Children can be added and removed from this list to modify this node.
+ */
+ public List<T> children() { return children; }
+
+ @Override void addSources(double weight,Map<Source,SourceNode> sources) {
+ for (T child : children)
+ child.addSources(weight*getScore(),sources);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/Source.java b/container-search/src/main/java/com/yahoo/search/intent/model/Source.java
new file mode 100644
index 00000000000..937b6ca02e4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/Source.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+/**
+ * A representation of a source. Sources have no structure but are just id of a
+ * set which is defined in the application.
+ * <p>
+ * Sources are Value Objects.
+ * <p>
+ * Source ids should be human readable, start with lower case and use camel casing
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Source {
+
+ private String id;
+
+ /** Creates an intent from a string id */
+ public Source(String id) {
+ this.id=id;
+ }
+
+ /** Returns the id of this source, never null */
+ public String getId() { return id; }
+
+ public @Override int hashCode() { return id.hashCode(); }
+
+ public @Override boolean equals(Object other) {
+ if (other==this) return true;
+ if ( ! (other instanceof Source)) return false;
+ return this.id.equals(((Source)other).id);
+ }
+
+ /** Returns the id of this source */
+ public @Override String toString() { return id; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/SourceNode.java b/container-search/src/main/java/com/yahoo/search/intent/model/SourceNode.java
new file mode 100644
index 00000000000..5f63ddbe8d1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/SourceNode.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.intent.model;
+
+import java.util.Map;
+
+/**
+ * A source node in an intent model tree. Represents a source with an appropriateness score
+ * (i.e the score of a source node is called <i>appropriateness</i>).
+ * Sources are ordered by decreasing appropriateness.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class SourceNode extends Node {
+
+ private Source source;
+
+ public SourceNode(Source source,double score) {
+ super(score);
+ this.source=source;
+ }
+
+ /** Sets the source of this node */
+ public void setSource(Source source) { this.source=source; }
+
+ /** Returns the source of this node */
+ public Source getSource() { return source; }
+
+ @Override void addSources(double weight,Map<Source,SourceNode> sources) {
+ SourceNode existing=sources.get(source);
+ if (existing!=null)
+ existing.increaseScore(weight*getScore());
+ else
+ sources.put(source,new SourceNode(source,weight*getScore()));
+ }
+
+ /** Returns source:appropriateness */
+ public @Override String toString() {
+ return source + ":" + getScore();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/intent/model/package-info.java b/container-search/src/main/java/com/yahoo/search/intent/model/package-info.java
new file mode 100644
index 00000000000..1e3e38208c5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/intent/model/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.intent.model;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/match/DocumentDb.java b/container-search/src/main/java/com/yahoo/search/match/DocumentDb.java
new file mode 100644
index 00000000000..f4be6861364
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/match/DocumentDb.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.match;
+
+import com.yahoo.document.Document;
+import com.yahoo.document.DocumentOperation;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * A searchable database of documents
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DocumentDb extends Searcher {
+
+ /**
+ * Put a document or apply an update to this document db
+ */
+ public void put(DocumentOperation op) {
+
+ }
+
+ /** Remove a document from this document db */
+ public void remove(Document document) {
+
+ }
+
+ /** Search this document db */
+ @Override
+ public Result search(Query query, Execution execution) {
+ Result r = execution.search(query);
+ return r;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/package-info.java b/container-search/src/main/java/com/yahoo/search/package-info.java
new file mode 100644
index 00000000000..96255d9108b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/package-info.java
@@ -0,0 +1,11 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * <b>The top level classes of the search container.</b> A Query represents the incoming request, which produces a Result
+ * by chained execution of a set of Searchers.
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplate.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplate.java
new file mode 100644
index 00000000000..8c421feae47
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplate.java
@@ -0,0 +1,82 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.FreezableComponent;
+import com.yahoo.search.pagetemplates.model.PageElement;
+import com.yahoo.search.pagetemplates.model.PageTemplateVisitor;
+import com.yahoo.search.pagetemplates.model.Section;
+import com.yahoo.search.pagetemplates.model.Source;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * A page template represents a particular way to organize a return page. It is a recursive structure of
+ * page template elements.
+ *
+ * @author bratseth
+ */
+public final class PageTemplate extends FreezableComponent implements PageElement {
+
+ /** The root section of this page */
+ private Section section=new Section();
+
+ /** The sources mentioned (recursively) in this page template, or null if this is not frozen */
+ private Set<Source> sources=null;
+
+ public PageTemplate(ComponentId id) {
+ super(id);
+ }
+
+ public void setSection(Section section) {
+ ensureNotFrozen();
+ this.section=section;
+ }
+
+ /** Returns the root section of this. This is never null. */
+ public Section getSection() { return section; }
+
+ /**
+ * Returns an unmodifiable set of all the sources this template <i>may</i> include (depending on choice resolution).
+ * If the template allows (somewhere) the "any" source (*), Source.any will be in the set returned.
+ * This operation is fast on frozen page templates (i.e at execution time).
+ */
+ public Set<Source> getSources() {
+ if (isFrozen()) return sources;
+ SourceVisitor sourceVisitor=new SourceVisitor();
+ getSection().accept(sourceVisitor);
+ return Collections.unmodifiableSet(sourceVisitor.getSources());
+ }
+
+ public @Override void freeze() {
+ if (isFrozen()) return;
+ resolvePlaceholders();
+ section.freeze();
+ sources=getSources();
+ super.freeze();
+ }
+
+ /** Validates and creates the necessary internal references between placeholders and their resolving choices */
+ private void resolvePlaceholders() {
+ try {
+ PlaceholderMappingVisitor placeholderMappingVisitor=new PlaceholderMappingVisitor();
+ accept(placeholderMappingVisitor);
+ accept(new PlaceholderReferenceCreatingVisitor(placeholderMappingVisitor.getMap()));
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException(this + " is invalid",e);
+ }
+ }
+
+ /** Accepts a visitor to this structure */
+ public @Override void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ section.accept(visitor);
+ }
+
+ public @Override String toString() {
+ return "page template '" + getId() + "'";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateRegistry.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateRegistry.java
new file mode 100644
index 00000000000..ffeec4b5dd1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateRegistry.java
@@ -0,0 +1,16 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates;
+
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.pagetemplates.engine.Resolver;
+
+/**
+ * @author bratseth
+ */
+public class PageTemplateRegistry extends ComponentRegistry<PageTemplate> {
+
+ public void register(PageTemplate pageTemplate) {
+ super.register(pageTemplate.getId(), pageTemplate);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateSearcher.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateSearcher.java
new file mode 100644
index 00000000000..eb928097e2d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/PageTemplateSearcher.java
@@ -0,0 +1,234 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates;
+
+import com.google.inject.Inject;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.intent.model.IntentModel;
+import com.yahoo.search.pagetemplates.config.PageTemplateConfigurer;
+import com.yahoo.search.pagetemplates.engine.Organizer;
+import com.yahoo.search.pagetemplates.engine.Resolution;
+import com.yahoo.search.pagetemplates.engine.Resolver;
+import com.yahoo.search.pagetemplates.engine.resolvers.DeterministicResolver;
+import com.yahoo.search.pagetemplates.engine.resolvers.RandomResolver;
+import com.yahoo.search.pagetemplates.engine.resolvers.ResolverRegistry;
+import com.yahoo.search.pagetemplates.model.Choice;
+import com.yahoo.search.pagetemplates.model.PageElement;
+import com.yahoo.search.pagetemplates.model.Source;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.*;
+
+/**
+ * Enables page optimization templates.
+ * This searcher should be placed before federation points in the search chain.
+ * <p>
+ * <b>Input query properties:</b>
+ * <ul>
+ * <li><code>page.idList</code> - a List&lt;String&gt; of id strings of the page templates this should choose between</li>
+ * <li><code>page.id</code> - a space-separated string of ids of the page templates this should choose between.
+ * This property is ignored if <code>page.idList</code> is set</li>
+ * <li><code>page.resolver</code> the id of the resolver to use to resolve choices. This is either the component id
+ * of a deployed resolver component, or one of the strings
+ * <code>native.deterministic</code> (which always pics the last choice) or <code>native.random</code></li>
+ * </ul>
+ *
+ * <b>Output query properties:</b>
+ * <ul>
+ * <li><code>page.ListOfPageTemplate</code>A List&lt;PageTemplate&gt;
+ * containing a list of the page templates used for this query
+ * </ul>
+ *
+ * <p>
+ * The set of page templates chosen for the query specifies a list of sources to be queries (the page template sources).
+ * In addition, the query may contain
+ * <ul>
+ * <li>a set of sources set explicitly in the Request, a query property or a searcher (the query model sources)
+ * <li>a set of sources specified in the {@link com.yahoo.search.intent.model.IntentModel} (the intent model sources)
+ * </ul>
+ * This searcher combines these sources into a single set in query.model by the following rules:
+ * <ul>
+ * <li>If the query model sources is set (not empty), it is not changed
+ * <li>If the page template sources contains the ANY source AND there is an intent model
+ * the query model sources is set to the union of the page template sources and the intent model sources
+ * <li>If the page template sources contains the ANY source AND there is no intent model,
+ * the query model sources is left empty (causing all sources to be queried)
+ * <li>Otherwise, the query model sources is set to the page template sources
+ * </ul>
+ *
+ * @author bratseth
+ */
+@Provides("PageTemplates")
+public class PageTemplateSearcher extends Searcher {
+
+ /** The name of the query property containing the resolved candidate page template list */
+ public static final CompoundName pagePageTemplateListName=new CompoundName("page.PageTemplateList");
+ /** The name of the query property containing a list of candidate pages to consider */
+ public static final CompoundName pageIdListName=new CompoundName("page.idList");
+ /** The name of the query property containing the page id to use */
+ public static final CompoundName pageIdName=new CompoundName("page.id");
+ /** The name of the query property containing the resolver id to use */
+ public static final CompoundName pageResolverName=new CompoundName("page.resolver");
+
+ private final ResolverRegistry resolverRegistry;
+
+ private final Organizer organizer = new Organizer();
+
+ private final PageTemplateRegistry templateRegistry;
+
+ /** Creates this from a configuration. This will be called by the container. */
+ @Inject
+ public PageTemplateSearcher(PageTemplatesConfig pageTemplatesConfig, ComponentRegistry<Resolver> resolverRegistry) {
+ this(PageTemplateConfigurer.toRegistry(pageTemplatesConfig), resolverRegistry.allComponents());
+ }
+
+ /**
+ * Creates this from an existing page template registry, using only built-in resolvers
+ *
+ * @param templateRegistry the page template registry. This will be frozen by this call.
+ * @param resolvers the resolvers to use, in addition to the default resolvers
+ */
+ public PageTemplateSearcher(PageTemplateRegistry templateRegistry, Resolver... resolvers) {
+ this(templateRegistry, Arrays.asList(resolvers));
+ }
+
+ private PageTemplateSearcher(PageTemplateRegistry templateRegistry, List<Resolver> resolvers) {
+ this.templateRegistry = templateRegistry;
+ templateRegistry.freeze();
+ this.resolverRegistry = new ResolverRegistry(resolvers);
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ // Pre execution: Choose template and sources
+ List<PageElement> pages=selectPageTemplates(query);
+ if (pages.isEmpty()) return execution.search(query); // Bypass if no page template chosen
+ addSources(pages,query);
+
+ // Set the page template list for inspection by other searchers
+ query.properties().set(pagePageTemplateListName, pages);
+
+ // Execute
+ Result result=execution.search(query);
+
+ // Post execution: Resolve choices and organize the result as dictated by the resolved template
+ Choice pageTemplateChoice=Choice.createSingletons(pages);
+ Resolution resolution=selectResolver(query).resolve(pageTemplateChoice,query,result);
+ organizer.organize(pageTemplateChoice,resolution,result);
+ return result;
+ }
+
+ /**
+ * Returns the list of page templates specified in the query, or the default if none, or the
+ * empty list if no default, never null.
+ */
+ private List<PageElement> selectPageTemplates(Query query) {
+ // Determine the list of page template ids
+ @SuppressWarnings("unchecked")
+ List<String> pageIds = (List<String>) query.properties().get(pageIdListName);
+ if (pageIds==null) {
+ String pageIdString=query.properties().getString(pageIdName,"").trim();
+ if (pageIdString.length()>0)
+ pageIds=Arrays.asList(pageIdString.split(" "));
+ }
+
+ // If none set, just return the default or null if none
+ if (pageIds==null) {
+ PageElement defaultPage=templateRegistry.getComponent("default");
+ return (defaultPage==null ? Collections.<PageElement>emptyList() : Collections.singletonList(defaultPage));
+ }
+
+ // Resolve the id list to page templates
+ List<PageElement> pages=new ArrayList<>(pageIds.size());
+ for (String pageId : pageIds) {
+ PageTemplate page=templateRegistry.getComponent(pageId);
+ if (page==null)
+ query.errors().add(ErrorMessage.createInvalidQueryParameter("Could not resolve requested page template '" +
+ pageId + "'"));
+ else
+ pages.add(page);
+ }
+
+ return pages;
+ }
+
+ private Resolver selectResolver(Query query) {
+ String resolverId=query.properties().getString(pageResolverName);
+ if (resolverId==null) return resolverRegistry.defaultResolver();
+ Resolver resolver=resolverRegistry.getComponent(resolverId);
+ if (resolver==null) throw new IllegalArgumentException("No page template resolver '" + resolverId + "'");
+ return resolver;
+ }
+
+ /** Sets query.getModel().getSources() to the right value and add source parameters specified in templates */
+ private void addSources(List<PageElement> pages,Query query) {
+ // Determine all wanted sources
+ Set<Source> pageSources=new HashSet<>();
+ for (PageElement page : pages)
+ pageSources.addAll(((PageTemplate)page).getSources());
+
+ addErrorIfSameSourceMultipleTimes(pages,pageSources,query);
+
+ if (query.getModel().getSources().size() > 0) {
+ // Add properties if the source list is set explicitly, but do not modify otherwise
+ addParametersForIncludedSources(pageSources,query);
+ return;
+ }
+
+ if (pageSources.contains(Source.any)) {
+ IntentModel intentModel=IntentModel.getFrom(query);
+ if (intentModel!=null) {
+ query.getModel().getSources().addAll(intentModel.getSourceNames());
+ addPageTemplateSources(pageSources,query);
+ }
+ // otherwise leave empty to search all
+ }
+ else { // Let the page templates decide
+ addPageTemplateSources(pageSources,query);
+ }
+ }
+
+ private void addPageTemplateSources(Set<Source> pageSources,Query query) {
+ for (Source pageSource : pageSources) {
+ if (pageSource==Source.any) continue;
+ query.getModel().getSources().add(pageSource.getName());
+ addParameters(pageSource,query);
+ }
+ }
+
+ private void addParametersForIncludedSources(Set<Source> sources,Query query) {
+ for (Source source : sources) {
+ if (source.parameters().size()>0 && query.getModel().getSources().contains(source.getName()))
+ addParameters(source,query);
+ }
+ }
+
+ /** Adds parameters specified in the source to the correct namespace in the query */
+ private void addParameters(Source source,Query query) {
+ for (Map.Entry<String,String> parameter : source.parameters().entrySet())
+ query.properties().set("source." + source.getName() + "." + parameter.getKey(),parameter.getValue());
+ }
+
+ /**
+ * Currently executing multiple queries to the same source with different parameter sets,
+ * is not supported. (Same parameter sets in multiple templates is supported,
+ * and will be just one entry in this set).
+ */
+ private void addErrorIfSameSourceMultipleTimes(List<PageElement> pages,Set<Source> sources,Query query) {
+ Set<String> sourceNames=new HashSet<>();
+ for (Source source : sources) {
+ if (sourceNames.contains(source.getName()))
+ query.errors().add(ErrorMessage.createInvalidQueryParameter(
+ "Querying the same source multiple times with different parameter sets as part of one query " +
+ "is not supported. " + pages + " requests this for source '" + source + "'"));
+ sourceNames.add(source.getName());
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderMappingVisitor.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderMappingVisitor.java
new file mode 100644
index 00000000000..2d61d17ade8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderMappingVisitor.java
@@ -0,0 +1,36 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates;
+
+import com.yahoo.search.pagetemplates.model.*;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Creates a map from placeholder id to the choice providing its value
+ * for all placeholder values visited.
+ * <p>
+ * This visitor will throw an IllegalArgumentException if the same placeholder id
+ * is referenced by two choices.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+class PlaceholderMappingVisitor extends PageTemplateVisitor {
+
+ private Map<String, MapChoice> placeholderIdToChoice=new LinkedHashMap<>();
+
+ public @Override void visit(MapChoice mapChoice) {
+ List<String> placeholderIds=mapChoice.placeholderIds();
+ for (String placeholderId : placeholderIds) {
+ MapChoice existingChoice=placeholderIdToChoice.put(placeholderId,mapChoice);
+ if (existingChoice!=null)
+ throw new IllegalArgumentException("placeholder id '" + placeholderId + "' is referenced by both " +
+ mapChoice + " and " + existingChoice + ": Only one reference is allowed");
+ }
+ }
+
+ public Map<String, MapChoice> getMap() { return placeholderIdToChoice; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderReferenceCreatingVisitor.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderReferenceCreatingVisitor.java
new file mode 100644
index 00000000000..2e22ad7291e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/PlaceholderReferenceCreatingVisitor.java
@@ -0,0 +1,30 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates;
+
+import com.yahoo.search.pagetemplates.model.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Creates references from all placeholders to the choices which resolves them.
+ * If a placeholder is encountered which is not resolved by any choice, an IllegalArgumentException is thrown.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+class PlaceholderReferenceCreatingVisitor extends PageTemplateVisitor {
+
+ private Map<String, MapChoice> placeholderIdToChoice=new HashMap<>();
+
+ public PlaceholderReferenceCreatingVisitor(Map<String, MapChoice> placeholderIdToChoice) {
+ this.placeholderIdToChoice=placeholderIdToChoice;
+ }
+
+ public @Override void visit(Placeholder placeholder) {
+ MapChoice choice=placeholderIdToChoice.get(placeholder.getId());
+ if (choice==null)
+ throw new IllegalArgumentException(placeholder + " is not referenced by any choice");
+ placeholder.setValueContainer(choice);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/SourceVisitor.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/SourceVisitor.java
new file mode 100644
index 00000000000..bf2685da56f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/SourceVisitor.java
@@ -0,0 +1,27 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates;
+
+import com.yahoo.search.pagetemplates.model.PageTemplateVisitor;
+import com.yahoo.search.pagetemplates.model.Source;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Visits a page template object structure and records the sources mentioned.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+class SourceVisitor extends PageTemplateVisitor {
+
+ private Set<Source> sources=new HashSet<>();
+
+ @Override
+ public void visit(Source source) {
+ sources.add(source);
+ }
+
+ /** Returns the live list of sources collected by this during visiting */
+ public Set<Source> getSources() { return sources; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateConfigurer.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateConfigurer.java
new file mode 100644
index 00000000000..5d106a6df8e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateConfigurer.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.config;
+
+import com.yahoo.config.subscription.ConfigSubscriber;
+import com.yahoo.search.pagetemplates.PageTemplatesConfig;
+import com.yahoo.io.reader.NamedReader;
+import com.yahoo.search.pagetemplates.PageTemplateRegistry;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provides a static method to convert a page template config into a PageTemplateRegistry.
+ * In addition, instances of this can be created to subscribe to config and keep an up to date registry reference.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class PageTemplateConfigurer {
+
+ /**
+ * Creates a new page template registry from the content of a config and returns it.
+ * The returned registry will <b>not</b> be frozen. This should be done, by calling freeze(), before it is used.
+ */
+ public static PageTemplateRegistry toRegistry(PageTemplatesConfig config) {
+ List<NamedReader> pageReaders=new ArrayList<>();
+ int pageNumber=0;
+ for (String pageString : config.page())
+ pageReaders.add(new NamedReader("page[" + pageNumber++ + "]",new StringReader(pageString)));
+ return new PageTemplateXMLReader().read(pageReaders,false);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateXMLReader.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateXMLReader.java
new file mode 100644
index 00000000000..46823f30cf2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/config/PageTemplateXMLReader.java
@@ -0,0 +1,355 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.config;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.io.reader.NamedReader;
+import com.yahoo.search.pagetemplates.PageTemplate;
+import com.yahoo.search.pagetemplates.PageTemplateRegistry;
+import com.yahoo.search.pagetemplates.model.*;
+import com.yahoo.search.query.Sorting;
+import com.yahoo.text.XML;
+import org.w3c.dom.Element;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.*;
+import java.util.logging.Logger;
+
+/**
+ * Reads all page template XML files from a given directory (or list of readers).
+ * Instances of this are for single-thread usage only.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class PageTemplateXMLReader {
+
+ private static Logger logger=Logger.getLogger(PageTemplateXMLReader.class.getName());
+
+ /** The registry being constructed */
+ private PageTemplateRegistry registry;
+
+ /** XML elements by page id - available after phase 1. Needed for includes. */
+ private Map<ComponentId, Element> pageElementsByPageId=new LinkedHashMap<>();
+
+ /**
+ * Reads all page template xml files in a given directory.
+ *
+ * @throws RuntimeException if <code>directory</code> is not a readable directory, or if there is some error in the XML
+ */
+ public PageTemplateRegistry read(String directory) {
+ List<NamedReader> pageReaders=new ArrayList<>();
+ try {
+ File dir=new File(directory);
+ if ( !dir.isDirectory() ) throw new IllegalArgumentException("Could not read page templates: '" +
+ directory + "' is not a valid directory.");
+
+ for (File file : sortFiles(dir)) {
+ if ( ! file.getName().endsWith(".xml")) continue;
+ pageReaders.add(new NamedReader(file.getName(),new FileReader(file)));
+ }
+
+ return read(pageReaders,true);
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Could not read page templates from '" + directory + "'",e);
+ }
+ finally {
+ for (NamedReader reader : pageReaders) {
+ try { reader.close(); } catch (IOException e) { }
+ }
+ }
+ }
+
+ /**
+ * Reads a single page template file.
+ *
+ * @throws RuntimeException if <code>fileName</code> is not a readable file, or if there is some error in the XML
+ */
+ public PageTemplate readFile(String fileName) {
+ NamedReader pageReader=null;
+ try {
+ File file=new File(fileName);
+ pageReader=new NamedReader(fileName,new FileReader(file));
+ String firstName=file.getName().substring(0,file.getName().length()-4);
+ return read(Collections.singletonList(pageReader),true).getComponent(firstName);
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Could not read the page template '" + fileName + "'",e);
+ }
+ finally {
+ if (pageReader!=null)
+ try { pageReader.close(); } catch (IOException e) { }
+ }
+ }
+
+ private List<File> sortFiles(File dir) {
+ ArrayList<File> files = new ArrayList<>();
+ files.addAll(Arrays.asList(dir.listFiles()));
+ Collections.sort(files);
+ return files;
+ }
+
+ /**
+ * Reads all page template xml files in a given list of readers. This is called from the Vespa configuration model.
+ *
+ * @param validateReaderNames should be set to true if the readers were created by files, not otherwise
+ * @throws RuntimeException if <code>directory</code> is not a readable directory, or if there is some error in the XML
+ */
+ public PageTemplateRegistry read(List<NamedReader> pageReaders,boolean validateReaderNames) {
+ // Initialize state
+ registry=new PageTemplateRegistry();
+
+ // Phase 1
+ pageElementsByPageId=createPages(pageReaders,validateReaderNames);
+ // Phase 2
+ readPages();
+ return registry;
+ }
+
+ private Map<ComponentId,Element> createPages(List<NamedReader> pageReaders,boolean validateReaderNames) {
+ Map<ComponentId,Element> pageElementsByPageId=new LinkedHashMap<>();
+ for (NamedReader reader : pageReaders) {
+ Element pageElement= XML.getDocument(reader).getDocumentElement();
+ if ( ! pageElement.getNodeName().equals("page")) {
+ logger.info("Ignoring '" + reader.getName() +
+ "': Expected XML root element 'page' but was '" + pageElement.getNodeName() + "'");
+ continue;
+ }
+ String idString=pageElement.getAttribute("id");
+
+ if (idString==null || idString.isEmpty())
+ throw new IllegalArgumentException("Page template '" + reader.getName() + "' has no 'id' attribute in the root element");
+ ComponentId id=new ComponentId(idString);
+ if (validateReaderNames)
+ validateFileName(reader.getName(),id,"page template");
+ registry.register(new PageTemplate(id));
+ pageElementsByPageId.put(id,pageElement);
+ }
+ return pageElementsByPageId;
+ }
+
+ /** Throws an exception if the name is not corresponding to the id */
+ private void validateFileName(final String actualName,ComponentId id,String artifactName) {
+ String expectedCanonicalFileName=id.toFileName();
+ String fileName=new File(actualName).getName();
+ fileName=stripXmlEnding(fileName);
+ String canonicalFileName=ComponentId.fromFileName(fileName).toFileName();
+ if ( ! canonicalFileName.equals(expectedCanonicalFileName))
+ throw new IllegalArgumentException("The file name of " + artifactName + " '" + id +
+ "' must be '" + expectedCanonicalFileName + ".xml' but was '" + actualName + "'");
+ }
+
+ private String stripXmlEnding(String fileName) {
+ if (!fileName.endsWith(".xml"))
+ throw new IllegalArgumentException("'" + fileName + "' should have a .xml ending");
+ else
+ return fileName.substring(0,fileName.length()-4);
+ }
+
+ private void readPages() {
+ for (Map.Entry<ComponentId,Element> pageElement : pageElementsByPageId.entrySet()) {
+ try {
+ PageTemplate page=registry.getComponent(pageElement.getValue().getAttribute("id"));
+ readPageContent(pageElement.getValue(),page);
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Could not read page template '" + pageElement.getKey() + "'",e);
+ }
+ }
+ }
+
+ private void readPageContent(Element pageElement,PageTemplate page) {
+ if (page.isFrozen()) return; // Already read
+ Section rootSection=new Section(page.getId().toString());
+ readSection(pageElement,rootSection);
+ page.setSection(rootSection);
+ page.freeze();
+ }
+
+ /** Fills a section with attributes and sub-elements from a "section" or "page" element */
+ private Section readSection(Element sectionElement,Section section) {
+ section.setLayout(Layout.fromString(sectionElement.getAttribute("layout")));
+ section.setRegion(sectionElement.getAttribute("region"));
+ section.setOrder(Sorting.fromString(sectionElement.getAttribute("order")));
+ section.setMax(readOptionalNumber(sectionElement,"max"));
+ section.setMin(readOptionalNumber(sectionElement,"min"));
+ section.elements().addAll(readSourceAttribute(sectionElement));
+ section.elements().addAll(readPageElements(sectionElement));
+ return section;
+ }
+
+ /** Returns all page elements found under the given node */
+ private List<PageElement> readPageElements(Element parent) {
+ List<PageElement> pageElements=new ArrayList<>();
+ for (Element child : XML.getChildren(parent)) {
+ if (child.getNodeName().equals("include"))
+ pageElements.addAll(readInclude(child));
+ else
+ addIfNonNull(readPageElement(child),pageElements);
+ }
+ return pageElements;
+ }
+
+ private void addIfNonNull(PageElement pageElement,List<PageElement> pageElements) {
+ if (pageElement!=null)
+ pageElements.add(pageElement);
+ }
+
+ /** Reads the direct descendant elements of an include */
+ private List<PageElement> readInclude(Element element) {
+ PageTemplate included=registry.getComponent(element.getAttribute("idref"));
+ if (included==null)
+ throw new IllegalArgumentException("Could not find page template '" + element.getAttribute("idref"));
+ readPageContent(pageElementsByPageId.get(included.getId()),included);
+ return included.getSection().elements(Section.class);
+ }
+
+ /** Returns the page element corresponding to the given node, never null */
+ private PageElement readPageElement(Element child) {
+ if (child.getNodeName().equals("choice"))
+ return readChoice(child);
+ else if (child.getNodeName().equals("source"))
+ return readSource(child);
+ else if (child.getNodeName().equals("placeholder"))
+ return readPlaceholder(child);
+ else if (child.getNodeName().equals("section"))
+ return readSection(child,new Section(child.getAttribute("id")));
+ else if (child.getNodeName().equals("renderer"))
+ return readRenderer(child);
+ else if (child.getNodeName().equals("parameter"))
+ return null; // read elsewhere
+ throw new IllegalArgumentException("Unknown node type '" + child.getNodeName() + "'");
+ }
+
+ private List<Source> readSourceAttribute(Element sectionElement) {
+ List<Source> sources=new ArrayList<>();
+ String sourceAttributeString=sectionElement.getAttribute("source");
+ if (sourceAttributeString!=null) {
+ for (String sourceName : sourceAttributeString.split(" ")) {
+ if (sourceName.isEmpty()) continue;
+ if ("*".equals(sourceName))
+ sources.add(Source.any);
+ else
+ sources.add(new Source(sourceName));
+ }
+ }
+ return sources;
+ }
+
+ private Source readSource(Element sourceElement) {
+ Source source=new Source(sourceElement.getAttribute("name"));
+ source.setUrl(nullIfEmpty(sourceElement.getAttribute("url")));
+ source.renderers().addAll(readPageElements(sourceElement));
+ /*
+ source.renderers().addAll(readRenderers(XML.children(sourceElement,"renderer")));
+ readChoices(sourceElement,source);
+ */
+ source.parameters().putAll(readParameters(sourceElement));
+ return source;
+ }
+
+ private String nullIfEmpty(String s) {
+ if (s==null) return s;
+ s=s.trim();
+ if (s.isEmpty()) return null;
+ return s;
+ }
+
+ private Placeholder readPlaceholder(Element placeholderElement) {
+ return new Placeholder(placeholderElement.getAttribute("id"));
+ }
+
+ private Renderer readRenderer(Element rendererElement) {
+ Renderer renderer =new Renderer(rendererElement.getAttribute("name"));
+ renderer.setRendererFor(nullIfEmpty(rendererElement.getAttribute("for")));
+ renderer.parameters().putAll(readParameters(rendererElement));
+ return renderer;
+ }
+
+ private int readOptionalNumber(Element element,String attributeName) {
+ String attributeValue=element.getAttribute(attributeName);
+ try {
+ if (attributeValue.isEmpty()) return -1;
+ return Integer.parseInt(attributeValue);
+ }
+ catch (NumberFormatException e) { // Suppress original exception as it conveys no useful information
+ throw new IllegalArgumentException("'" + attributeName + "' in " + element + " must be a number, not '" + attributeValue + "'");
+ }
+ }
+
+ private AbstractChoice readChoice(Element choiceElement) {
+ String method=nullIfEmpty(choiceElement.getAttribute("method"));
+ if (XML.getChildren(choiceElement,"map").size()>0)
+ return readMapChoice(choiceElement,method);
+ else
+ return readNonMapChoice(choiceElement,method);
+ }
+
+ private MapChoice readMapChoice(Element choiceElement,String method) {
+ Element mapElement=XML.getChildren(choiceElement,"map").get(0);
+ MapChoice map=new MapChoice();
+ map.setMethod(method);
+
+ map.placeholderIds().addAll(readSpaceSeparatedAttribute("to",mapElement));
+ for (Element value : XML.getChildren(mapElement)) {
+ if ("item".equals(value.getNodeName()))
+ map.values().add(readPageElements(value));
+ else
+ map.values().add(Collections.singletonList(readPageElement(value)));
+ }
+ return map;
+ }
+
+ private Choice readNonMapChoice(Element choiceElement,String method) {
+ Choice choice=new Choice();
+ choice.setMethod(method);
+
+ for (Element alternative : XML.getChildren(choiceElement)) {
+ if (alternative.getNodeName().equals("alternative")) // Explicit alternative container
+ choice.alternatives().add(readPageElements(alternative));
+ else if (alternative.getNodeName().equals("include")) // Implicit include
+ choice.alternatives().add(readInclude(alternative));
+ else // Other implicit
+ choice.alternatives().add(Collections.singletonList(readPageElement(alternative)));
+ }
+ return choice;
+ }
+
+ /*
+ private void readChoices(Element sourceElement,Source source) {
+ for (Element choiceElement : XML.children(sourceElement,"choice")) {
+ for (Element alternative : XML.children(choiceElement)) {
+ if ("alternative".equals(alternative.getNodeName())) // Explicit alternative container
+ source.renderer().alternatives().addAll(readRenderers(XML.children(alternative)));
+ else // Implicit alternative - yes implicit and explicit may be combined
+ source.renderer().alternatives().addAll(readRenderers(Collections.singletonList(alternative)));
+ }
+ }
+ }
+ */
+
+ private Map<String,String> readParameters(Element containingElement) {
+ List<Element> parameterElements=XML.getChildren(containingElement,"parameter");
+ if (parameterElements.size()==0) return Collections.emptyMap(); // Shortcut
+
+ Map<String,String> parameters=new LinkedHashMap<>();
+ for (Element parameter : parameterElements) {
+ String key=parameter.getAttribute("name");
+ String value=XML.getValue(parameter);
+ parameters.put(key,value);
+ }
+ return parameters;
+ }
+
+ private List<String> readSpaceSeparatedAttribute(String attributeName, Element containingElement) {
+ List<String> values=new ArrayList<>();
+ String attributeString=nullIfEmpty(containingElement.getAttribute(attributeName));
+ if (attributeString!=null) {
+ for (String value : attributeString.split(" "))
+ values.add(value);
+ }
+ return values;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/config/package-info.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/config/package-info.java
new file mode 100644
index 00000000000..40cdfb691ab
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/config/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.pagetemplates.config;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Organizer.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Organizer.java
new file mode 100644
index 00000000000..00e154d460b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Organizer.java
@@ -0,0 +1,177 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine;
+
+import com.yahoo.search.Result;
+import com.yahoo.search.pagetemplates.PageTemplate;
+import com.yahoo.search.pagetemplates.model.*;
+import com.yahoo.search.pagetemplates.result.SectionHitGroup;
+import com.yahoo.search.query.Sorting;
+import com.yahoo.search.result.*;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Reorganizes and prunes a result as prescribed by a resolved template.
+ * This class is multithread safe.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Organizer {
+
+ /**
+ * Organizes the given result
+ *
+ * @param templateChoice a choice between singleton lists of PageTemplates
+ * @param resolution the resolution of (at least) the template choice and all choices contained in that template
+ * @param result the result to organize
+ */
+ public void organize(Choice templateChoice, Resolution resolution, Result result) {
+ PageTemplate template=(PageTemplate)templateChoice.get(resolution.getResolution(templateChoice)).get(0);
+ SectionHitGroup sectionGroup =toGroup(template.getSection(),resolution,result);
+ ErrorHit errors=result.hits().getErrorHit();
+
+ // transfer state from existing hit
+ sectionGroup.setQuery(result.hits().getQuery());
+ if (errors!=null && errors instanceof DefaultErrorHit)
+ sectionGroup.add((DefaultErrorHit)errors);
+ for (Iterator<Map.Entry<String, Object>> it = result.hits().fieldIterator(); it.hasNext(); ) {
+ Map.Entry<String, Object> field = it.next();
+ sectionGroup.setField(field.getKey(), field.getValue());
+ }
+
+ result.setHits(sectionGroup);
+ }
+
+ /** Creates the hit group corresponding to a section, drawing data from the given result */
+ private SectionHitGroup toGroup(Section section,Resolution resolution,Result result) {
+ SectionHitGroup sectionGroup=new SectionHitGroup("section:" + section.getId());
+ setField("id",section.getId(),sectionGroup);
+ sectionGroup.setLeaf(section.elements(Section.class).size()==0);
+ setField("layout",section.getLayout().getName(),sectionGroup);
+ setField("region",section.getRegion(),sectionGroup);
+
+ List<String> sourceList=new ArrayList<>();
+ renderElements(resolution, result, sectionGroup, sourceList, section.elements());
+
+ // Trim to max
+ if (section.getMax()>=0)
+ sectionGroup.trim(0,section.getMax());
+ if (sectionGroup.size()>1)
+ assignOrderer(section,resolution,sourceList,sectionGroup);
+
+ return sectionGroup;
+ }
+
+ private void renderElements(Resolution resolution, Result result, SectionHitGroup sectionGroup, List<String> sourceList, List<PageElement> elements) {
+ for (PageElement element : elements) {
+ if (element instanceof Section) {
+ sectionGroup.add(toGroup((Section)element,resolution,result));
+ }
+ else if (element instanceof Source) {
+ addSource(resolution,(Source)element,sectionGroup,result,sourceList);
+ }
+ else if (element instanceof Renderer) {
+ sectionGroup.renderers().add((Renderer)element);
+ }
+ else if (element instanceof Choice) {
+ Choice choice=(Choice)element;
+ if (choice.isEmpty()) continue; // Ignore
+ int chosen=resolution.getResolution(choice);
+ renderElements(resolution, result, sectionGroup, sourceList, choice.alternatives().get(chosen));
+ }
+ else if (element instanceof Placeholder) {
+ Placeholder placeholder =(Placeholder)element;
+ List<PageElement> mappedElements=
+ resolution.getResolution(placeholder.getValueContainer()).get(placeholder.getId());
+ renderElements(resolution,result,sectionGroup,sourceList,mappedElements);
+ }
+ }
+ }
+
+ private void setField(String fieldName,Object value,Hit to) {
+ if (value==null) return;
+ to.setField(fieldName,value);
+ }
+
+ private void addSource(Resolution resolution,Source source,SectionHitGroup sectionGroup,Result result,List<String> sourceList) {
+ renderElements(resolution,result,sectionGroup, sourceList, source.renderers());
+ /*
+ for (PageElement element : source.renderers()) {
+ if (element instanceof Renderer)
+ if (renderer.isEmpty()) continue;
+ sectionGroup.renderers().add(renderer.get(resolution.getResolution(renderer)));
+ }
+ */
+
+ if (source.getUrl()==null)
+ addHitsFromSource(source,sectionGroup,result,sourceList);
+ else
+ sectionGroup.sources().add(source); // source to be rendered by the frontend
+ }
+
+ private void addHitsFromSource(Source source,SectionHitGroup sectionGroup,Result result,List<String> sourceList) {
+ if (source==Source.any) { // Add any source not added yet
+ for (Hit hit : result.hits()) {
+ if ( ! (hit instanceof HitGroup)) continue;
+ String groupId=hit.getId().stringValue();
+ if ( ! groupId.startsWith("source:")) continue;
+ String sourceName=groupId.substring(7);
+ if (sourceList.contains(sourceName)) continue;
+ sectionGroup.addAll(((HitGroup)hit).asList());
+ sourceList.add(sourceName); // Add *'ed sources explicitly
+ }
+ }
+ else {
+ HitGroup sourceGroup=(HitGroup)result.hits().get("source:" + source.getName());
+ if (sourceGroup!=null)
+ sectionGroup.addAll(sourceGroup.asList());
+ sourceList.add(source.getName()); // Add even if not found - may be added later
+ }
+ }
+
+ private void assignOrderer(Section section,Resolution resolution,List<String> sourceList,HitGroup group) {
+ if (section.getOrder()==null) { // then sort by relevance, source
+ group.setOrderer(new HitSortOrderer(new RelevanceComparator(new SourceOrderComparator(sourceList))));
+ return;
+ }
+
+ // replace a source field comparison by one which knows the source list order
+ // and add default sorting at the end if necessary
+ Sorting sorting=section.getOrder();
+ int rankIndex=-1;
+ int sourceIndex=-1;
+ for (int i=0; i<sorting.fieldOrders().size(); i++) {
+ Sorting.FieldOrder order=sorting.fieldOrders().get(i);
+ if ("[relevance]".equals(order.getFieldName()) || "[rank]".equals(order.getFieldName()))
+ rankIndex=i;
+ else if (order.getFieldName().equals("[source]"))
+ sourceIndex=i;
+ }
+
+ ChainableComparator comparator;
+ Sorting beforeSource=null;
+ Sorting afterSource=null;
+ if (sourceIndex>=0) { // replace alphabetical sorting on source by sourceList order sorting
+ if (sourceIndex>0) // sort fields before the source
+ beforeSource=new Sorting(new ArrayList<>(sorting.fieldOrders().subList(0,sourceIndex)));
+ if (sorting.fieldOrders().size()>sourceIndex+1) // sort fields after the source
+ afterSource=new Sorting(new ArrayList<>(sorting.fieldOrders().subList(sourceIndex+1,sorting.fieldOrders().size()+1)));
+
+ comparator=new SourceOrderComparator(sourceList, FieldComparator.create(afterSource));
+ if (beforeSource!=null)
+ comparator=new FieldComparator(beforeSource,comparator);
+
+ }
+ else if (rankIndex>=0) { // add sort by source at the end
+ comparator=new FieldComparator(sorting,new SourceOrderComparator(sourceList));
+ }
+ else { // add sort by rank,source at the end
+ comparator=new FieldComparator(sorting,new RelevanceComparator(new SourceOrderComparator(sourceList)));
+ }
+ group.setOrderer(new HitSortOrderer(comparator));
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/RelevanceComparator.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/RelevanceComparator.java
new file mode 100644
index 00000000000..7489768b5a3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/RelevanceComparator.java
@@ -0,0 +1,29 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine;
+
+import com.yahoo.search.result.ChainableComparator;
+import com.yahoo.search.result.Hit;
+
+import java.util.Comparator;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+class RelevanceComparator extends ChainableComparator {
+
+ /**
+ * Creates a relevance comparator, with an optional secondary comparator.
+ * If the secondary is null, the intrinsic hit order is used as secondary.
+ */
+ public RelevanceComparator(Comparator<Hit> secondaryComparator) {
+ super(secondaryComparator);
+ }
+
+ public @Override int compare(Hit h1,Hit h2) {
+ int relevanceComparison=h2.getRelevance().compareTo(h1.getRelevance());
+ if (relevanceComparison!=0) return relevanceComparison;
+
+ return super.compare(h1,h2);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolution.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolution.java
new file mode 100644
index 00000000000..d67faf805ad
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolution.java
@@ -0,0 +1,66 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine;
+
+import com.yahoo.search.pagetemplates.model.Choice;
+import com.yahoo.search.pagetemplates.model.MapChoice;
+import com.yahoo.search.pagetemplates.model.PageElement;
+
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A resolution of choices within a template.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Resolution {
+
+ /** A record of choices made as choice → alternative index (id) */
+ private Map<Choice,Integer> choiceResolutions=new IdentityHashMap<>();
+
+ /** A of map choices made as choice → mapping */
+ private Map<MapChoice,Map<String,List<PageElement>>> mapChoiceResolutions=
+ new IdentityHashMap<>();
+
+ public void addChoiceResolution(Choice choice,int alternativeIndex) {
+ choiceResolutions.put(choice,alternativeIndex);
+ }
+
+ public void addMapChoiceResolution(MapChoice choice, Map<String,List<PageElement>> mapping) {
+ mapChoiceResolutions.put(choice,mapping);
+ }
+
+ /**
+ * Returns the resolution of a choice.
+ *
+ * @return the (0-base) index of the choice made. If the given choice has exactly one alternative,
+ * 0 is always returned (whether or not the choice has been attempted resolved).
+ * @throws IllegalArgumentException if the choice is empty, or if it has multiple alternatives but have not
+ * been resolved in this
+ */
+ public int getResolution(Choice choice) {
+ if (choice.alternatives().size()==1) return 0;
+ if (choice.isEmpty()) throw new IllegalArgumentException("Cannot return a resolution of empty " + choice);
+ Integer resolution=choiceResolutions.get(choice);
+ if (resolution==null) throw new IllegalArgumentException(this + " has no resolution of " + choice);
+ return resolution;
+ }
+
+ /**
+ * Returns the resolution of a map choice.
+ *
+ * @return the chosen mapping - entries from placeholder id to the values to use at the location of that placeholder
+ * @throws IllegalArgumentException if this choice has not been resolved in this
+ */
+ public Map<String,List<PageElement>> getResolution(MapChoice choice) {
+ Map<String,List<PageElement>> resolution=mapChoiceResolutions.get(choice);
+ if (resolution==null) throw new IllegalArgumentException(this + " has no resolution of " + choice);
+ return resolution;
+ }
+
+ public @Override String toString() {
+ return "a resolution of " + choiceResolutions.size() + " choices";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolver.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolver.java
new file mode 100644
index 00000000000..4972b0e4689
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/Resolver.java
@@ -0,0 +1,114 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.component.ComponentId;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.pagetemplates.PageTemplate;
+import com.yahoo.search.pagetemplates.model.Choice;
+import com.yahoo.search.pagetemplates.model.MapChoice;
+import com.yahoo.search.pagetemplates.model.PageTemplateVisitor;
+
+/**
+ * Superclass of page template choice resolvers.
+ * <p>
+ * Subclasses overrides one of the two resolve methods to either resolve each choices individually
+ * or look at all choices at once.
+ * <p>
+ * All subclasses of this must be multithread safe. I.e multiple calls may be made
+ * to resolve at the same time from different threads.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class Resolver extends AbstractComponent {
+
+ public Resolver(String id) {
+ super(new ComponentId(id));
+ }
+
+ public Resolver(ComponentId id) {
+ super(id);
+ }
+
+ protected Resolver() {}
+
+ /**
+ * Override this to resolve choices. Before retuning this method <i>must</i> resolve the given choice
+ * between a set of page templates <i>and</i> all choices found recursively within the <i>chosen</i>
+ * page template. It is permissible but not required to add solutions also to choices present within those
+ * templates which are not chosen.
+ * <p>
+ * This default implementation creates a Resolution and calls
+ * <code>resolve(choice/mapChoice,query,result,resolution)</code> first on the given page template choice, then
+ * on each choice found in that temnplate. This provides a simple API to resolvers which make each choice
+ * independently.
+ *
+ * @param pageTemplate the choice of page templates to resolve - a choice containing singleton lists of PageTemplate elements
+ * @param query the query, from which information useful for correct resolution can be found
+ * @param result the result, from which further information useful for correct resolution can be found
+ * @return the resolution of the choices contained in the given page template
+ */
+ public Resolution resolve(Choice pageTemplate, Query query, Result result) {
+ Resolution resolution=new Resolution();
+ resolve(pageTemplate,query,result,resolution);
+ PageTemplate chosenPageTemplate=(PageTemplate)pageTemplate.get(resolution.getResolution(pageTemplate)).get(0);
+ ChoiceResolverVisitor choiceResolverVisitor=new ChoiceResolverVisitor(query,result,resolution);
+ chosenPageTemplate.accept(choiceResolverVisitor);
+ return choiceResolverVisitor.getResolution();
+ }
+
+ /**
+ * Override this to resolve <i>each</i> choice independently.
+ * This default implementation does nothing.
+ *
+ * @param choice the choice to resolve
+ * @param query the query for which this should be resolved, typically used to extract features
+ * @param result the result for which this should be resolved, typically used to extract features
+ * @param resolution the set of resolutions made so far, to which this should be added:
+ * <code>resolution.addChoiceResolution(choice,chosenAlternativeIndex)</code>
+ */
+ public void resolve(Choice choice,Query query,Result result,Resolution resolution) {
+ }
+
+ /**
+ * Override this to resolve <i>each</i> map choice independently.
+ * This default implementation does nothing.
+ *
+ * @param choice the choice to resolve
+ * @param query the query for which this should be resolved, typically used to extract features
+ * @param result the result for which this should be resolved, typically used to extract features
+ * @param resolution the set of resolutions made so far, to which this should be added:
+ * <code>resolution.addMapChoiceResolution(choice,chosenMapping)</code>
+ */
+ public void resolve(MapChoice choice,Query query,Result result,Resolution resolution) {
+ }
+
+ private class ChoiceResolverVisitor extends PageTemplateVisitor {
+
+ private Resolution resolution;
+
+ private Query query;
+
+ private Result result;
+
+ public ChoiceResolverVisitor(Query query,Result result,Resolution resolution) {
+ this.query=query;
+ this.result=result;
+ this.resolution=resolution;
+ }
+
+ public @Override void visit(Choice choice) {
+ if (choice.alternatives().size()<2) return; // No choice...
+ resolve(choice,query,result,resolution);
+ }
+
+ public @Override void visit(MapChoice choice) {
+ resolve(choice,query,result,resolution);
+ }
+
+ public Resolution getResolution() { return resolution; }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/SourceOrderComparator.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/SourceOrderComparator.java
new file mode 100644
index 00000000000..b4cd01f0c36
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/SourceOrderComparator.java
@@ -0,0 +1,57 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine;
+
+import com.yahoo.search.result.ChainableComparator;
+import com.yahoo.search.result.Hit;
+
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+class SourceOrderComparator extends ChainableComparator {
+
+ private final List<String> sourceOrder;
+
+ /**
+ * Creates a source order comparator, with no secondary
+ *
+ * @param sourceOrder the sort order of list names. This list gets owned by this and must not be modified
+ */
+ public SourceOrderComparator(List<String> sourceOrder) {
+ this(sourceOrder,null);
+ }
+
+ /**
+ * Creates a source order comparator, with an optional secondary comparator.
+ *
+ * @param sourceOrder the sort order of list names. This list gets owned by this and must not be modified
+ * @param secondaryComparator the comparator to use as secondary, or null to use the intrinsic hit order
+ */
+ public SourceOrderComparator(List<String> sourceOrder,Comparator<Hit> secondaryComparator) {
+ super(secondaryComparator);
+ this.sourceOrder=sourceOrder;
+ }
+
+ public @Override int compare(Hit h1,Hit h2) {
+ int primaryOrder=sourceOrderCompare(h1,h2);
+ if (primaryOrder!=0) return primaryOrder;
+
+ return super.compare(h1,h2);
+ }
+
+ private int sourceOrderCompare(Hit h1,Hit h2) {
+ String h1Source=h1.getSource();
+ String h2Source=h2.getSource();
+
+ if (h1Source==null && h2Source==null) return 0;
+ if (h1Source==null) return 1; // No source -> last
+ if (h2Source==null) return -1; // No source -> last
+
+ if (h1Source.equals(h2Source)) return 0;
+
+ return sourceOrder.indexOf(h1Source)-sourceOrder.indexOf(h2Source);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/package-info.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/package-info.java
new file mode 100644
index 00000000000..6628156cb33
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.pagetemplates.engine;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/DeterministicResolver.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/DeterministicResolver.java
new file mode 100644
index 00000000000..32ed54a6775
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/DeterministicResolver.java
@@ -0,0 +1,56 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine.resolvers;
+
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.pagetemplates.engine.Resolution;
+import com.yahoo.search.pagetemplates.engine.Resolver;
+import com.yahoo.search.pagetemplates.model.Choice;
+import com.yahoo.search.pagetemplates.model.MapChoice;
+import com.yahoo.search.pagetemplates.model.PageElement;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A resolver which
+ * <ul>
+ * <li>Always chooses the <i>last</i> alternative of any Choice
+ * <li>Always maps values to placeholders in the order they are listed in the map definition of any MapChoice
+ * </ul>
+ * This is useful for testing.
+ * <p>
+ * The id of this if <code>native.deterministic</code>
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DeterministicResolver extends Resolver {
+ public static final String nativeId = "native.deterministic";
+
+ public DeterministicResolver() {}
+
+ protected DeterministicResolver(String id) {
+ super(id);
+ }
+
+ /** Chooses the last alternative of any choice */
+ @Override
+ public void resolve(Choice choice, Query query, Result result, Resolution resolution) {
+ resolution.addChoiceResolution(choice,choice.alternatives().size()-1);
+ }
+
+ /** Chooses a mapping which is always by the literal order given in the source template */
+ @Override
+ public void resolve(MapChoice choice,Query query,Result result,Resolution resolution) {
+ Map<String, List<PageElement>> mapping=new HashMap<>();
+ // Map 1-1 by order
+ List<String> placeholderIds=choice.placeholderIds();
+ List<List<PageElement>> valueList=choice.values();
+ int i=0;
+ for (String placeholderId : placeholderIds)
+ mapping.put(placeholderId,valueList.get(i++));
+ resolution.addMapChoiceResolution(choice,mapping);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/RandomResolver.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/RandomResolver.java
new file mode 100644
index 00000000000..5f06c66795d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/RandomResolver.java
@@ -0,0 +1,50 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine.resolvers;
+
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.pagetemplates.engine.Resolution;
+import com.yahoo.search.pagetemplates.engine.Resolver;
+import com.yahoo.search.pagetemplates.model.Choice;
+import com.yahoo.search.pagetemplates.model.MapChoice;
+import com.yahoo.search.pagetemplates.model.PageElement;
+
+import java.util.*;
+
+/**
+ * A resolver which makes all choices by random.
+ * The id of this is <code>native.random</code>.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class RandomResolver extends Resolver {
+
+ public static final String nativeId = "native.random";
+
+ private Random random = new Random(System.currentTimeMillis()); // Use of this is multithread safe
+
+ public RandomResolver() {}
+
+ protected RandomResolver(String id) {
+ super(id);
+ }
+
+ /** Chooses the last alternative of any choice */
+ @Override
+ public void resolve(Choice choice, Query query, Result result, Resolution resolution) {
+ resolution.addChoiceResolution(choice,random.nextInt(choice.alternatives().size()));
+ }
+
+ /** Chooses a mapping which is always by the literal order given in the source template */
+ @Override
+ public void resolve(MapChoice choice,Query query,Result result,Resolution resolution) {
+ Map<String, List<PageElement>> mapping=new HashMap<>();
+ // Draw a random element from the value list on each iteration and assign it to a placeholder
+ List<String> placeholderIds=choice.placeholderIds();
+ List<List<PageElement>> valueList=new ArrayList<>(choice.values());
+ for (String placeholderId : placeholderIds)
+ mapping.put(placeholderId,valueList.remove(random.nextInt(valueList.size())));
+ resolution.addMapChoiceResolution(choice,mapping);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/ResolverRegistry.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/ResolverRegistry.java
new file mode 100644
index 00000000000..0bbbec655bd
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/ResolverRegistry.java
@@ -0,0 +1,59 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.engine.resolvers;
+
+import com.google.inject.Inject;
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.pagetemplates.engine.Resolver;
+
+import java.util.List;
+import java.util.logging.Logger;
+
+/**
+ * A registry of available resolver components
+ *
+ * @author bratseth
+ */
+public class ResolverRegistry extends ComponentRegistry<Resolver> {
+
+ private final Resolver defaultResolver;
+
+ public ResolverRegistry(List<Resolver> resolvers) {
+ addBuiltInResolvers();
+ for (Resolver component : resolvers)
+ registerResolver(component);
+ defaultResolver = decideDefaultResolver();
+ freeze();
+ }
+
+ private void addBuiltInResolvers() {
+ registerResolver(createNativeDeterministicResolver());
+ registerResolver(createNativeRandomResolver());
+ }
+
+ private Resolver decideDefaultResolver() {
+ Resolver defaultResolver = getComponent("default");
+ if (defaultResolver != null) return defaultResolver;
+ return getComponent("native.random");
+ }
+
+ private Resolver createNativeRandomResolver() {
+ RandomResolver resolver = new RandomResolver();
+ resolver.initId(ComponentId.fromString(RandomResolver.nativeId));
+ return resolver;
+ }
+
+ private DeterministicResolver createNativeDeterministicResolver() {
+ DeterministicResolver resolver = new DeterministicResolver();
+ resolver.initId(ComponentId.fromString(DeterministicResolver.nativeId));
+ return resolver;
+ }
+
+ private void registerResolver(Resolver resolver) {
+ super.register(resolver.getId(), resolver);
+ }
+
+ public Resolver defaultResolver() { return defaultResolver; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/package-info.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/package-info.java
new file mode 100644
index 00000000000..c1e3f218480
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/engine/resolvers/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.pagetemplates.engine.resolvers;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/AbstractChoice.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/AbstractChoice.java
new file mode 100644
index 00000000000..069598b2e02
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/AbstractChoice.java
@@ -0,0 +1,31 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.component.provider.FreezableClass;
+
+/**
+ * Abstract superclass of various kinds of choices.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class AbstractChoice extends FreezableClass implements PageElement {
+
+ private String method;
+
+ /**
+ * Returns the choice method to use - a string interpreted by the resolver in use,
+ * or null to use any available method
+ */
+ public String getMethod() { return method; }
+
+ public void setMethod(String method) {
+ ensureNotFrozen();
+ this.method=method;
+ }
+
+ // TODO: is this really choices between classes in general, or e.g. subclasses of Section?
+ /** Returns true if this choice is (partially or completely) a choice between the given type */
+ @SuppressWarnings("rawtypes")
+ public abstract boolean isChoiceBetween(Class pageTemplateModelClass);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Choice.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Choice.java
new file mode 100644
index 00000000000..a1932012236
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Choice.java
@@ -0,0 +1,114 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import java.util.*;
+
+/**
+ * A choice between some alternative lists of page elements.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public final class Choice extends AbstractChoice {
+
+ private List<List<PageElement>> alternatives=new ArrayList<>(3);
+
+ /** Creates an empty choice */
+ public Choice() { }
+
+ /** Creates a choice having a single alternative having a single page element */
+ public static Choice createSingleton(PageElement singletonAlternative) {
+ Choice choice=new Choice();
+ choice.alternatives().add(createSingletonList(singletonAlternative));
+ return choice;
+ }
+
+ /** Creates a choice in which each alternative consists of a single element */
+ public static Choice createSingletons(List<PageElement> alternatives) {
+ Choice choice=new Choice();
+ for (PageElement alternative : alternatives)
+ choice.alternatives().add(createSingletonList(alternative));
+ return choice;
+ }
+
+ private static List<PageElement> createSingletonList(PageElement member) {
+ List<PageElement> list=new ArrayList<>();
+ list.add(member);
+ return list;
+ }
+
+ /**
+ * Creates a choice between some alternatives. This method takes a copy of the given lists.
+ */
+ public Choice(List<List<PageElement>> alternatives) {
+ for (List<PageElement> alternative : alternatives)
+ this.alternatives.add(new ArrayList<>(alternative));
+ }
+
+ /**
+ * Returns the alternatives of this as a live reference to the alternatives of this.
+ * The list and elements may be modified unless this is frozen. This is never null.
+ */
+ public List<List<PageElement>> alternatives() { return alternatives; }
+
+ /** Convenience shorthand of <code>return alternatives().get(index)</code> */
+ public List<PageElement> get(int index) {
+ return alternatives.get(index);
+ }
+
+ /** Convenience shorthand for <code>if (alternative!=null) alternatives().add(alternative)</code> */
+ public void add(List<PageElement> alternative) {
+ if (alternative!=null)
+ alternatives.add(new ArrayList<>(alternative));
+ }
+
+ /** Returns true only if there are no alternatives in this */
+ public boolean isEmpty() { return alternatives.size()==0; }
+
+ /** Answers true if this is either a choice between the given class, or between Lists of the given class */
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ @Override
+ public boolean isChoiceBetween(Class pageTemplateModelElementClass) {
+ List firstNonEmpty=null;
+ for (List<PageElement> value : alternatives) {
+ if (pageTemplateModelElementClass.isAssignableFrom(value.getClass())) return true;
+ if (value instanceof List) {
+ List listValue=(List)value;
+ if (listValue.size()>0)
+ firstNonEmpty=listValue;
+ }
+ }
+ if (firstNonEmpty==null) return false;
+ return (pageTemplateModelElementClass.isAssignableFrom(firstNonEmpty.get(0).getClass()));
+ }
+
+ @Override
+ public void freeze() {
+ if (isFrozen()) return;
+ super.freeze();
+ for (ListIterator<List<PageElement>> i=alternatives.listIterator(); i.hasNext(); ) {
+ List<PageElement> alternative=i.next();
+ for (PageElement alternativeElement : alternative)
+ alternativeElement.freeze();
+ i.set(Collections.unmodifiableList(alternative));
+ }
+ alternatives= Collections.unmodifiableList(alternatives);
+ }
+
+ /** Accepts a visitor to this structure */
+ @Override
+ public void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ for (List<PageElement> alternative : alternatives) {
+ for (PageElement alternativeElement : alternative)
+ alternativeElement.accept(visitor);
+ }
+ }
+
+ @Override
+ public String toString() {
+ if (alternatives.isEmpty()) return "(empty choice)";
+ if (alternatives.size()==1) return alternatives.get(0).toString();
+ return "a choice between " + alternatives;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Layout.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Layout.java
new file mode 100644
index 00000000000..f8e00b78787
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Layout.java
@@ -0,0 +1,50 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+/**
+ * The layout of a section
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+// This is not made an enum, to allow the value set to be extendible.
+// It is not explicitly made immutable
+// to enable adding of internal state later (esp. parameters).
+// If this becomes mutable, the creation scheme must be changed
+// such that each fromString returns a unique instance, and
+// the name must become a (immutable) type.
+public class Layout {
+
+ /** The built in "column" layout */
+ public static final Layout column=new Layout("column");
+ /** The built in "row" layout */
+ public static final Layout row=new Layout("row");
+
+ private String name;
+
+ public Layout(String name) {
+ this.name=name;
+ }
+
+ public String getName() { return name; }
+
+ public @Override int hashCode() { return name.hashCode(); }
+
+ public @Override boolean equals(Object o) {
+ if (o==this) return true;
+ if (! (o instanceof Layout)) return false;
+ Layout other=(Layout)o;
+ return this.name.equals(other.name);
+ }
+
+ /** Returns a layout having this string as name, or null if the given string is null or empty */
+ public static Layout fromString(String layout) {
+ //if (layout==null) return null;
+ //if (layout)
+ if (layout.equals("column")) return column;
+ if (layout.equals("row")) return row;
+ return new Layout(layout);
+ }
+
+ public @Override String toString() { return "layout '" + name + "'"; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/MapChoice.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/MapChoice.java
new file mode 100644
index 00000000000..33c3bba9a77
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/MapChoice.java
@@ -0,0 +1,69 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A choice between different possible mapping functions of a set of values to a set of placeholder ids.
+ * A <i>resolution</i> of this choice consists of choosing a unique value for each placeholder id
+ * (hence a map choice is valid iff there are at least as many values as placeholder ids).
+ * <p>
+ * Each unique set of mappings (pairs) from values to placeholder ids is a separate possible
+ * alternative of this choice. The alternatives are not listed explicitly but are generated as needed.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class MapChoice extends AbstractChoice {
+
+ private List<String> placeholderIds=new ArrayList<>();
+
+ private List<List<PageElement>> values=new ArrayList<>();
+
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ @Override
+ public boolean isChoiceBetween(Class pageTemplateModelElementClass) {
+ List<PageElement> firstNonEmpty=null;
+ for (List<PageElement> value : values)
+ if (value.size()>0)
+ firstNonEmpty=value;
+ if (firstNonEmpty==null) return false;
+ return (pageTemplateModelElementClass.isAssignableFrom(firstNonEmpty.get(0).getClass()));
+ }
+
+ /**
+ * Returns the placeholder ids (the "to" of the mapping) of this as a live reference which can be modified unless
+ * this is frozen.
+ */
+ public List<String> placeholderIds() { return placeholderIds; }
+
+ /**
+ * Returns the values (the "from" of the mapping) of this as a live reference which can be modified unless
+ * this is frozen. Note that each single choice of values within this is also a list of values. This is
+ * the inner list.
+ */
+ public List<List<PageElement>> values() { return values; }
+
+ @Override
+ public void freeze() {
+ if (isFrozen()) return;
+ super.freeze();
+ placeholderIds=Collections.unmodifiableList(placeholderIds);
+ values=Collections.unmodifiableList(values);
+ }
+
+ /** Accepts a visitor to this structure */
+ public @Override void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ for (List<PageElement> valueEntry : values)
+ for (PageElement value : valueEntry)
+ value.accept(visitor);
+ }
+
+ @Override
+ public String toString() {
+ return "mapping to placeholders " + placeholderIds;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageElement.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageElement.java
new file mode 100644
index 00000000000..fba58f069ec
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageElement.java
@@ -0,0 +1,16 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.component.provider.Freezable;
+
+/**
+ * Implemented by all page template model classes
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public interface PageElement extends Freezable {
+
+ /** Accepts a visitor to this structure */
+ public void accept(PageTemplateVisitor visitor);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageTemplateVisitor.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageTemplateVisitor.java
new file mode 100644
index 00000000000..d7ebd3d1169
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/PageTemplateVisitor.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.search.pagetemplates.PageTemplate;
+
+/**
+ * Superclass of visitors over the page template object structure
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class PageTemplateVisitor {
+
+ /** Called each time a page template is encountered. This default implementation does nothing */
+ public void visit(PageTemplate pageTemplate) {
+ }
+
+ /** Called each time a source or source placeholder is encountered. This default implementation does nothing */
+ public void visit(Source source) {
+ }
+
+ /** Called each time a section or section placeholder is encountered. This default implementation does nothing */
+ public void visit(Section section) {
+ }
+
+ /** Called each time a renderer is encountered. This default implementation does nothing */
+ public void visit(Renderer renderer) {
+ }
+
+ /** Called each time a choice is encountered. This default implementation does nothing */
+ public void visit(Choice choice) {
+ }
+
+ /** Called each time a map choice is encountered. This default implementation does nothing */
+ public void visit(MapChoice choice) {
+ }
+
+ /** Called each time a placeholder is encountered. This default implementation does nothing */
+ public void visit(Placeholder placeholder) {
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Placeholder.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Placeholder.java
new file mode 100644
index 00000000000..cf7a85fc779
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Placeholder.java
@@ -0,0 +1,49 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+/**
+ * A source placeholder is replaced with a list of source instances at evaluation time.
+ * Source placeholders may not have any content themselves - attempting to call any setter on this
+ * results in a IllegalStateException.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Placeholder implements PageElement {
+
+ private String id;
+
+ private MapChoice valueContainer=null;
+
+ /** Creates a source placeholder with an id. */
+ public Placeholder(String id) {
+ this.id=id;
+ }
+
+ public String getId() { return id; }
+
+ /** Returns the element which contains the value(s) of this placeholder. Never null. */
+ public MapChoice getValueContainer() { return valueContainer; }
+
+ public void setValueContainer(MapChoice valueContainer) { this.valueContainer=valueContainer; }
+
+ public @Override void freeze() {}
+
+ /** Accepts a visitor to this structure */
+ public @Override void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ }
+
+ public @Override String toString() {
+ return "source placeholder '" + id + "'";
+ }
+
+ /**
+ * This method always returns false, is a Placeholder always is mutable.
+ * (freeze() is a NOOP.)
+ */
+ @Override
+ public boolean isFrozen() {
+ return false;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Renderer.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Renderer.java
new file mode 100644
index 00000000000..4564ceeef3c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Renderer.java
@@ -0,0 +1,88 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.component.provider.FreezableClass;
+import com.yahoo.protect.Validator;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * A description of a way to present data items from a source.
+ * All data items has a default renderer. This can be overridden or parametrized by
+ * an explicit renderer.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public final class Renderer extends FreezableClass implements PageElement {
+
+ private String name;
+
+ private String rendererFor;
+
+ private Map<String,String> parameters =new LinkedHashMap<>();
+
+ public Renderer(String name) {
+ setName(name);
+ }
+
+ /**
+ * Returns the name of this renderer (never null).
+ * The name should be recognized by the system receiving results for rendering
+ */
+ public String getName() { return name; }
+
+ public final void setName(String name) {
+ ensureNotFrozen();
+ Validator.ensureNotNull("renderer name",name);
+ this.name=name;
+ }
+
+ /**
+ * Returns the name of the kind of data this is a renderer for.
+ * This is used to allow frontends to dispatch the right data items (hits) to
+ * the right renderer in the case where the data consists of a heterogeneous list.
+ * <p>
+ * This is null if this is a renderer for a whole section, or if this is a renderer
+ * for all kinds of data from a particular source <i>and</i> this is not frozen.
+ * <p>
+ * Otherwise, it is either the name of the source this is the renderer for,
+ * <i>or</i> the renderer for all data items having this name as a <i>type</i>.
+ * <p>
+ * This, a (frontend) dispatcher of data to renderers should for each data item:
+ * <ul>
+ * <li>use the renderer having the same name as any <code>type</code> name set of the data item
+ * <li>if no such renderer, use the renderer having <code>rendererFor</code> equal to the data items <code>source</code>
+ * <li>if no such renderer, use a default renderer
+ * </ul>
+ */
+ public String getRendererFor() { return rendererFor; }
+
+ public void setRendererFor(String rendererFor) {
+ ensureNotFrozen();
+ this.rendererFor=rendererFor;
+ }
+
+ /**
+ * Returns the parameters of this renderer as a live reference (never null).
+ * The parameters will be passed to the renderer with each result
+ */
+ public Map<String,String> parameters() { return parameters; }
+
+ public @Override void freeze() {
+ if (isFrozen()) return;
+ super.freeze();
+ parameters = Collections.unmodifiableMap(parameters);
+ }
+
+ /** Accepts a visitor to this structure */
+ public @Override void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ }
+ public @Override String toString() {
+ return "renderer '" + name + "'";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Section.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Section.java
new file mode 100644
index 00000000000..0a980419853
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Section.java
@@ -0,0 +1,177 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.component.provider.FreezableClass;
+import com.yahoo.search.query.Sorting;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * An element of a page template corresponding to a physical area of the layout of the final physical page.
+ * Pages are freezable - once frozen calling a setter will cause an IllegalStateException, and returned
+ * live collection references are unmodifiable
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Section extends FreezableClass implements PageElement {
+
+ private final String id;
+
+ private Layout layout=Layout.column;
+
+ private String region;
+
+ /** The elements of this - sources, subsections etc. and/or choices of the same */
+ private List<PageElement> elements=new ArrayList<>();
+
+ /** Filtered versions of elements pre-calculated at freeze time */
+ private List<PageElement> sections, sources, renderers;
+
+ private int max=-1;
+
+ private int min=-1;
+
+ private Sorting order=null;
+
+ private static AtomicInteger nextId=new AtomicInteger();
+
+ public Section() {
+ this(null);
+ }
+
+ /** Creates a section with an id (or null if no id) */
+ public Section(String id) {
+ if (id==null || id.isEmpty())
+ this.id=String.valueOf("section_" + nextId.incrementAndGet());
+ else
+ this.id=id;
+ }
+
+ /** Returns a unique id of this section within the page. Used for referencing and identification. Never null. */
+ public String getId() { return id; }
+
+ /**
+ * Returns the layout identifier describing the kind of layout which should be used by the rendering engine to
+ * lay out the content of this section. This is never null. Default: "column".
+ */
+ public Layout getLayout() { return layout; }
+
+ /** Sets the layout. If the layout is set to null it will become Layout.column */
+ public void setLayout(Layout layout) {
+ ensureNotFrozen();
+ if (layout==null) layout=Layout.column;
+ this.layout=layout;
+ }
+
+ /**
+ * Returns the identifier telling the layout of the containing section where this section should be placed.
+ * Permissible values, and whether this is mandatory is determined by the particular layout identifier of the parent.
+ * May be null if a placement is not required by the containing layout, or if this is the top-level section.
+ * This is null by default.
+ */
+ public String getRegion() { return region; }
+
+ public void setRegion(String region) {
+ ensureNotFrozen();
+ this.region=region;
+ }
+
+ /**
+ * Returns the elements of this - sources, subsections and presentations and/or choices of these,
+ * as a live reference which can be modified to change the content of this (unless this is frozen).
+ * <p>
+ * All elements are kept in a single list to allow multiple elements of each type to be nested within separate
+ * choices, and to maintain the internal order of elements of various types, which is sometimes significant.
+ * To extract a certain kind of elements (say, sources), the element list must be traversed to collect
+ * all source elements as well as all choices of sources.
+ * <p>
+ * This list is never null but may be empty.
+ */
+ public List<PageElement> elements() { return elements; }
+
+ /**
+ * Convenience method which returns the elements <b>and choices</b> of the given type in elements as a
+ * read-only list. Not that as this returns both concrete elements and choices betwen them,
+ * the list element cannot be case to the given class - this must be used in conjunction
+ * with a resolve which contains the resolution to the choices.
+ *
+ * @param pageTemplateModelElementClass type to returns elements and choices of, a subtype of PageElement
+ */
+ public List<PageElement> elements(@SuppressWarnings("rawtypes") Class pageTemplateModelElementClass) {
+ if (isFrozen()) { // Use precalculated lists
+ if (pageTemplateModelElementClass==Section.class)
+ return sections;
+ else if (pageTemplateModelElementClass==Source.class)
+ return sources;
+ else if (pageTemplateModelElementClass==Renderer.class)
+ return renderers;
+ }
+ return createElementList(pageTemplateModelElementClass);
+ }
+
+ @SuppressWarnings("unchecked")
+ private List<PageElement> createElementList(@SuppressWarnings("rawtypes") Class pageTemplateModelElementClass) {
+ List<PageElement> filteredElements=new ArrayList<>();
+ for (PageElement element : elements) {
+ if (pageTemplateModelElementClass.isAssignableFrom(element.getClass()))
+ filteredElements.add(element);
+ else if (element instanceof AbstractChoice)
+ if (((AbstractChoice)element).isChoiceBetween(pageTemplateModelElementClass))
+ filteredElements.add(element);
+ }
+ return Collections.unmodifiableList(filteredElements);
+ }
+
+ /** Returns the choice of ways to sort immediate children in this, or empty meaning sort by default order (relevance) */
+ public Sorting getOrder() { return order; }
+
+ public void setOrder(Sorting order) {
+ ensureNotFrozen();
+ this.order=order;
+ }
+
+ /** Returns max number of (immediate) elements/sections permissible within this, -1 means unrestricted. Default: -1. */
+ public int getMax() { return max; }
+
+ public void setMax(int max) {
+ ensureNotFrozen();
+ this.max=max;
+ }
+
+ /** Returns min number of (immediate) elements/sections desired within this, -1 means unrestricted. Default: -1. */
+ public int getMin() { return min; }
+
+ public void setMin(int min) {
+ ensureNotFrozen();
+ this.min=min;
+ }
+
+ public @Override void freeze() {
+ if (isFrozen()) return;
+
+ for (PageElement element : elements)
+ element.freeze();
+ elements=Collections.unmodifiableList(elements);
+ sections=createElementList(Section.class);
+ sources=createElementList(Source.class);
+ renderers=createElementList(Renderer.class);
+
+ super.freeze();
+ }
+
+ /** Accepts a visitor to this structure */
+ public @Override void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ for (PageElement element : elements)
+ element.accept(visitor);
+ }
+
+ public @Override String toString() {
+ if (id==null || id.isEmpty()) return "a section";
+ return "section '" + id + "'";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Source.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Source.java
new file mode 100644
index 00000000000..91c403eae84
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/Source.java
@@ -0,0 +1,137 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.component.provider.FreezableClass;
+import com.yahoo.protect.Validator;
+
+import java.util.*;
+
+/**
+ * A source mentioned in a page template.
+ * <p>
+ * Two sources are equal if they have the same name and parameters.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Source extends FreezableClass implements PageElement {
+
+ /** The "any" source - used to mark that any source is acceptable here */
+ public static final Source any=new Source("*",true);
+
+ /** The obligatory name of a source */
+ private String name;
+
+ private List<PageElement> renderers =new ArrayList<>();
+
+ private Map<String,String> parameters =new LinkedHashMap<>();
+
+ private String url;
+
+ /** The precalculated hashCode of this object, or 0 if this is not frozen */
+ private int hashCode=0;
+
+ public Source(String name) {
+ this(name,false);
+ }
+
+ /** Creates a source and optionally immediately freezes it */
+ private Source(String name,boolean freeze) {
+ setName(name);
+ if (freeze)
+ freeze();
+ }
+
+ /** Returns the name of this source (never null) */
+ public String getName() { return name; }
+
+ public final void setName(String name) {
+ ensureNotFrozen();
+ Validator.ensureNotNull("Source name",name);
+ this.name=name;
+ }
+
+ /** Returns the url of this source or null if none */
+ public String getUrl() { return url; }
+
+ /**
+ * Sets the url of this source. If a source has an url (i.e this returns non-null), the content of
+ * the url is <i>not</i> fetched - fetching is left to the frontend by exposing this url in the result.
+ */
+ public void setUrl(String url) {
+ ensureNotFrozen();
+ this.url=url;
+ }
+
+ /**
+ * Returns the renderers or choices of renderers to apply on individual items of this source
+ * <p>
+ * If this contains multiple renderers/choices, they are to be used on different types of hits returned by this source.
+ */
+ public List<PageElement> renderers() { return renderers; }
+
+ /**
+ * Returns the parameters of this source as a live reference (never null).
+ * The parameters will be passed to the provider getting source data.
+ */
+ public Map<String,String> parameters() { return parameters; }
+
+ public @Override void freeze() {
+ if (isFrozen()) return;
+ for (PageElement element : renderers) {
+ if (element instanceof Renderer) {
+ assignRendererForIfNotSet((Renderer)element);
+ }
+ else if (element instanceof Choice) {
+ for (List<PageElement> renderersAlternative : ((Choice)element).alternatives()) {
+ for (PageElement rendererElement : renderersAlternative) {
+ Renderer renderer=(Renderer)rendererElement;
+ if (renderer.getRendererFor()==null)
+ renderer.setRendererFor(name);
+ }
+ }
+ }
+ element.freeze();
+ }
+ parameters = Collections.unmodifiableMap(parameters);
+ hashCode=hashCode();
+ super.freeze();
+ }
+
+ private void assignRendererForIfNotSet(Renderer renderer) {
+ if (renderer.getRendererFor()==null)
+ renderer.setRendererFor(name);
+ }
+
+ /** Accepts a visitor to this structure */
+ public @Override void accept(PageTemplateVisitor visitor) {
+ visitor.visit(this);
+ for (PageElement renderer : renderers)
+ renderer.accept(visitor);
+ }
+
+ public @Override int hashCode() {
+ if (isFrozen()) return hashCode;
+ int hashCode=name.hashCode();
+ int i=0;
+ for (Map.Entry<String,String> parameter : parameters.entrySet())
+ hashCode+=i*17*parameter.getKey().hashCode()+i*31*parameter.getValue().hashCode();
+ return hashCode;
+ }
+
+ public @Override boolean equals(Object other) {
+ if (other==this) return true;
+ if (! (other instanceof Source)) return false;
+ Source otherSource=(Source)other;
+ if (! this.name.equals(otherSource.name)) return false;
+ if (this.parameters.size() != otherSource.parameters.size()) return false;
+ for (Map.Entry<String,String> thisParameter : this.parameters.entrySet())
+ if ( ! thisParameter.getValue().equals(otherSource.parameters.get(thisParameter.getKey())))
+ return false;
+ return true;
+ }
+
+ public @Override String toString() {
+ return "source '" + name + "'";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/model/package-info.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/package-info.java
new file mode 100644
index 00000000000..22a004d7555
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/model/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.pagetemplates.model;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/package-info.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/package-info.java
new file mode 100644
index 00000000000..0368351a6dc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.pagetemplates;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/result/SectionHitGroup.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/result/SectionHitGroup.java
new file mode 100644
index 00000000000..00f6c6350fc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/result/SectionHitGroup.java
@@ -0,0 +1,52 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.pagetemplates.result;
+
+import com.yahoo.search.pagetemplates.model.Renderer;
+import com.yahoo.search.pagetemplates.model.Source;
+import com.yahoo.search.result.HitGroup;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A hit group corresponding to a section - contains some additional information
+ * in proper getters and setters which is used during rendering.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class SectionHitGroup extends HitGroup {
+
+ private static final long serialVersionUID = -9048845836777953538L;
+ private List<Source> sources=new ArrayList<>(0);
+ private List<Renderer> renderers=new ArrayList<>(0);
+ private final String displayId;
+
+ private boolean leaf=false;
+
+ public SectionHitGroup(String id) {
+ super(id);
+ if (id.startsWith("section:section_"))
+ displayId=null; // Don't display section ids when not named explicitly
+ else
+ displayId=id;
+ types().add("section");
+ }
+
+ @Override
+ public String getDisplayId() { return displayId; }
+
+ /**
+ * Returns the live, modifiable list of sources which are not fetched by the framework but should
+ * instead be included in the result
+ */
+ public List<Source> sources() { return sources; }
+
+ /** Returns the live, modifiable list of renderers in this section */
+ public List<Renderer> renderers() { return renderers; }
+
+ /** Returns whether this is a leaf section containing no subsections */
+ public boolean isLeaf() { return leaf; }
+
+ public void setLeaf(boolean leaf) { this.leaf=leaf; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/pagetemplates/result/package-info.java b/container-search/src/main/java/com/yahoo/search/pagetemplates/result/package-info.java
new file mode 100644
index 00000000000..7d006aad551
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/pagetemplates/result/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.pagetemplates.result;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/Model.java b/container-search/src/main/java/com/yahoo/search/query/Model.java
new file mode 100644
index 00000000000..588580dda4d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/Model.java
@@ -0,0 +1,521 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.yahoo.language.Language;
+import com.yahoo.language.Linguistics;
+import com.yahoo.language.LocaleFactory;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.TaggableItem;
+import com.yahoo.prelude.query.textualrepresentation.TextualQueryRepresentation;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.parser.Parsable;
+import com.yahoo.search.query.parser.Parser;
+import com.yahoo.search.query.parser.ParserEnvironment;
+import com.yahoo.search.query.parser.ParserFactory;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.*;
+
+import static com.yahoo.text.Lowercase.toLowerCase;
+
+/**
+ * The parameters defining the recall of a query.
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Model implements Cloneable {
+
+ /** The type representing the property arguments consumed by this */
+ private static final QueryProfileType argumentType;
+ private static final CompoundName argumentTypeName;
+
+ public static final String MODEL = "model";
+ public static final String PROGRAM = "program";
+ public static final String QUERY_STRING = "queryString";
+ public static final String TYPE = "type";
+ public static final String FILTER = "filter";
+ public static final String DEFAULT_INDEX = "defaultIndex";
+ public static final String LANGUAGE = "language";
+ public static final String ENCODING = "encoding";
+ public static final String SOURCES = "sources";
+ public static final String SEARCH_PATH = "searchPath";
+ public static final String RESTRICT = "restrict";
+
+ static {
+ argumentType =new QueryProfileType(MODEL);
+ argumentType.setStrict(true);
+ argumentType.setBuiltin(true);
+ //argumentType.addField(new FieldDescription(PROGRAM, "string", "yql")); // TODO: Custom type
+ argumentType.addField(new FieldDescription(QUERY_STRING, "string", "query"));
+ argumentType.addField(new FieldDescription(TYPE, "string", "type"));
+ argumentType.addField(new FieldDescription(FILTER, "string","filter"));
+ argumentType.addField(new FieldDescription(DEFAULT_INDEX, "string", "default-index def-idx defidx"));
+ argumentType.addField(new FieldDescription(LANGUAGE, "string", "language lang"));
+ argumentType.addField(new FieldDescription(ENCODING, "string", "encoding"));
+ argumentType.addField(new FieldDescription(SOURCES, "string", "sources search"));
+ argumentType.addField(new FieldDescription(SEARCH_PATH, "string", "searchpath"));
+ argumentType.addField(new FieldDescription(RESTRICT, "string", "restrict"));
+ argumentType.freeze();
+ argumentTypeName=new CompoundName(argumentType.getId().getName());
+ }
+
+ public static QueryProfileType getArgumentType() { return argumentType; }
+
+ /** The name of the query property used for generating hit count estimate queries. */
+ public static final CompoundName ESTIMATE = new CompoundName("hitcountestimate");
+
+ private String encoding = null;
+ private String queryString = "";
+ private String filter = null;
+ private Language language = null;
+ private Locale locale = null;
+ private QueryTree queryTree = null; // The actual query. This is lazily created from the program
+ private String defaultIndex = null;
+ private Query.Type type = Query.Type.ALL;
+ private Query parent;
+ private Set<String> sources=new LinkedHashSet<>();
+ private Set<String> restrict=new LinkedHashSet<>();
+ private String searchPath;
+ private String documentDbName = null;
+ private Execution execution=new Execution(new Execution.Context(null, null, null, null, null));
+
+ public Model(Query query) {
+ setParent(query);
+ }
+
+ /**
+ * Creates trace a message of language detection results into this Model
+ * instance's parent query. Do note this will give bogus results if the
+ * Execution instance is not set correctly. This is done automatically
+ * inside {@link Execution#search(Query)}. If tracing the same place as
+ * creating the query instance, {@link #setExecution(Execution)} has to be
+ * invoked first with the same Execution instance the query is intended to
+ * be run by.
+ */
+ public void traceLanguage() {
+ if (getParent().getTraceLevel()<2) return;
+ if (language != null) {
+ getParent().trace("Language " + getLanguage() + " specified directly as a parameter", false, 2);
+ }
+ else {
+ Language l = getParsingLanguage();
+ // Don't include the query, it will trigger query parsing
+ getParent().trace("Detected language: " + l, false, 2);
+ getParent().trace("Language " + l + " determined by " +
+ (Language.fromEncoding(encoding) != Language.UNKNOWN ? "query encoding" :
+ "the characters in the terms") + ".", false, 2);
+ }
+ }
+
+ /**
+ * Gets the language to use for parsing. If this is explicitly set, that language is returned, otherwise
+ * it is guessed from the query string. If this does not yield an actual language, English is
+ * returned as the default.
+ *
+ * @return the language determined, never null
+ */
+ public Language getParsingLanguage() {
+ Language language = getLanguage();
+ if (language != null) {
+ return language;
+ }
+ language = Language.fromEncoding(encoding);
+ if (language != Language.UNKNOWN) {
+ return language;
+ }
+ Linguistics linguistics = execution.context().getLinguistics();
+ if (linguistics != null) {
+ language = linguistics.getDetector().detect(queryString, null).getLanguage();
+ }
+ if (language != Language.UNKNOWN) {
+ return language;
+ }
+ return Language.ENGLISH;
+ }
+
+ /** Returns the explicitly set parsing language of this query model, or null if none */
+ public Language getLanguage() { return language; }
+
+ /** Explicitly sets the language to be used during parsing */
+ public void setLanguage(Language language) { this.language = language; }
+
+ /**
+ * <p>Explicitly sets the language to be used during parsing. The argument is first normalized by replacing
+ * underscores with hyphens (to support locale strings being used as RFC 5646 language tags), and then forwarded to
+ * {@link #setLocale(String)} so that the Locale information of the tag is preserved.</p>
+ *
+ * @param language The language string to parse.
+ * @see #getLanguage()
+ * @see #setLocale(String)
+ */
+ public void setLanguage(String language) {
+ setLocale(language.replace("_", "-"));
+ }
+
+ /**
+ * <p>Returns the explicitly set parsing locale of this query model, or null if none.</p>
+ *
+ * @return The locale of this.
+ * @see #setLocale(Locale)
+ */
+ public Locale getLocale() {
+ return locale;
+ }
+
+ /**
+ * <p>Explicitly sets the locale to be used during parsing. This method also calls {@link #setLanguage(Language)}
+ * with the corresponding {@link Language} instance.</p>
+ *
+ * @param locale The locale to set.
+ * @see #getLocale()
+ * @see #setLanguage(Language)
+ */
+ public void setLocale(Locale locale) {
+ this.locale = locale;
+ setLanguage(Language.fromLocale(locale));
+ }
+
+ /**
+ * <p>Explicitly sets the locale to be used during parsing. This creates a Locale instance from the given language
+ * tag, and passes that to {@link #setLocale(Locale)}.</p>
+ *
+ * @param languageTag The language tag to parse.
+ * @see #setLocale(Locale)
+ */
+ public void setLocale(String languageTag) {
+ setLocale(LocaleFactory.fromLanguageTag(languageTag));
+ }
+
+ /** Returns the encoding used in the query as a lowercase string */
+ public String getEncoding() { return encoding; }
+
+ /** Sets the encoding which was used in the received query string */
+ public void setEncoding(String encoding) {
+ this.encoding = toLowerCase(encoding);
+ }
+
+ /** Set the path for which backend nodes to forward the search too. */
+ public void setSearchPath(String searchPath) { this.searchPath = searchPath; }
+
+ public String getSearchPath() { return searchPath; }
+
+ /**
+ * Set the query from a string. This will not be parsed into a query tree until that tree is attempted accessed.
+ * Note that setting this will clear the current query tree. Usually, this should <i>not</i> be modified -
+ * changes to the query should be implemented as modifications on the query tree structure.
+ * <p>
+ * Passing null causes this to be set to an empty string.
+ */
+ public void setQueryString(String queryString) {
+ if (queryString==null) queryString="";
+ this.queryString = queryString;
+ queryTree=null; // Cause parsing of the new query string next time the tree is accessed
+ }
+
+ /**
+ * Returns the query string which caused the original query tree of this model to come about.
+ * Note that changes to the query tree are <b>not</b> reflected in this query string.
+ *
+ * @return the original (or reassigned) query string - never null
+ */
+ public String getQueryString() { return queryString; }
+
+ /**
+ * Returns the query as an object structure.
+ * This causes parsing of the query string if it has changed since this was last called
+ * (i.e query parsing is lazy)
+ */
+ public QueryTree getQueryTree() {
+ if (queryTree == null) {
+ Parser parser = ParserFactory.newInstance(type, ParserEnvironment.fromExecutionContext(execution.context()));
+ queryTree = parser.parse(Parsable.fromQueryModel(this));
+ if (parent.getTraceLevel() >= 2) {
+ parent.trace("Query parsed to: " + parent.yqlRepresentation(), 2);
+ }
+ }
+ return queryTree;
+ }
+
+ /**
+ * Returns the filter string set for this query.
+ * The filter is included in the query tree at the time the query tree is parsed
+ */
+ public String getFilter() { return filter; }
+
+ /**
+ * Sets the filter string set for this query.
+ * The filter is included in the query tree at the time the query tree is parsed.
+ * Setting this does <i>not</i> cause the query to be reparsed.
+ */
+ public void setFilter(String filter) { this.filter = filter; }
+
+ /**
+ * Returns the default index for this query.
+ * The default index is taken into account at the time the query tree is parsed.
+ */
+ public String getDefaultIndex() { return defaultIndex; }
+
+ /**
+ * Sets the default index for this query.
+ * The default index is taken into account at the time the query tree is parsed.
+ * Setting this does <i>not</i> cause the query to be reparsed.
+ */
+ public void setDefaultIndex(String defaultIndex) { this.defaultIndex = defaultIndex; }
+
+ /**
+ * Sets the query type of for this query.
+ * The type is taken into account at the time the query tree is parsed.
+ */
+ public Query.Type getType() { return type; }
+
+ /**
+ * Sets the query type of for this query.
+ * The type is taken into account at the time the query tree is parsed.
+ * Setting this does <i>not</i> cause the query to be reparsed.
+ */
+ public void setType(Query.Type type) { this.type = type; }
+
+ /**
+ * Sets the query type of for this query.
+ * The type is taken into account at the time the query tree is parsed.
+ * Setting this does <i>not</i> cause the query to be reparsed.
+ */
+ public void setType(String typeString) { this.type = Query.Type.getType(typeString); }
+
+ public boolean equals(Object o) {
+ if ( ! (o instanceof Model)) return false;
+
+ Model other = (Model) o;
+ if ( ! (
+ QueryHelper.equals(other.encoding, this.encoding) &&
+ QueryHelper.equals(other.language, this.language) &&
+ QueryHelper.equals(other.searchPath, this.searchPath) &&
+ QueryHelper.equals(other.sources, this.sources) &&
+ QueryHelper.equals(other.restrict, this.restrict) &&
+ QueryHelper.equals(other.defaultIndex, this.defaultIndex) &&
+ QueryHelper.equals(other.type, this.type) ))
+ return false;
+
+ if (other.queryTree == null && this.queryTree == null) // don't cause query parsing
+ return QueryHelper.equals(other.queryString, this.queryString) &&
+ QueryHelper.equals(other.filter, this.filter);
+ else // make sure we compare a parsed variant of both
+ return QueryHelper.equals(other.getQueryTree(), this.getQueryTree());
+ }
+
+ @Override
+ public int hashCode() {
+ return getClass().hashCode() +
+ QueryHelper.combineHash(encoding,filter,language,getQueryTree(),sources,restrict,defaultIndex,type,searchPath);
+ }
+
+
+ public Object clone() {
+ try {
+ Model clone = (Model) super.clone();
+ if (queryTree != null)
+ clone.queryTree = this.queryTree.clone();
+ if (sources !=null)
+ clone.sources = new LinkedHashSet<>(this.sources);
+ if (restrict !=null)
+ clone.restrict = new LinkedHashSet<>(this.restrict);
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException("Someone inserted a noncloneable superclass",e);
+ }
+ }
+
+ public Model cloneFor(Query q) {
+ Model model = (Model) this.clone();
+ model.setParent(q);
+ return model;
+ }
+
+ /** returns the query owning this, never null */
+ public Query getParent() { return parent; }
+
+ /** Assigns the query owning this */
+ public void setParent(Query parent) {
+ if (parent==null) throw new NullPointerException("A query models owner cannot be null");
+ this.parent = parent;
+ }
+
+ /** Sets the set of sources this query will search from a comma-separated string of source names */
+ public void setSources(String sourceString) {
+ setFromString(sourceString,sources);
+ }
+
+ /**
+ * Returns the set of sources this query will search.
+ * This set can be modified to change the set of sources. If all sources are to be searched, this returns
+ * an empty set
+ *
+ * @return the set of sources to search, never null
+ */
+ public Set<String> getSources() { return sources; }
+
+ /**
+ * Sets the set of types (document type or search definition names) this query will search from a
+ * comma-separated string of type names. This is useful to narrow a search to just a subset of the types available
+ * from a sources
+ */
+ public void setRestrict(String restrictString) {
+ setFromString(restrictString,restrict);
+ }
+
+ /**
+ * Returns the set of types this query will search.
+ * This set can be modified to change the set of types. If all types are to be searched, this returns
+ * an empty set.
+ *
+ * @return the set of types to search, never null
+ */
+ public Set<String> getRestrict() { return restrict; }
+
+ /** Sets the execution working on this. For internal use. */
+ public void setExecution(Execution execution) {
+ if (execution==this.execution) return;
+
+ // If not already coupled, bind the trace of the new execution into the existing execution trace
+ if (execution.trace().traceNode().isRoot()
+ && execution.trace().traceNode() != this.execution.trace().traceNode().root()) {
+ this.execution.trace().traceNode().add(execution.trace().traceNode());
+ }
+
+ this.execution = execution;
+ }
+
+ /** Sets the document database this will search - a document type */
+ public void setDocumentDb(String documentDbName) {
+ this.documentDbName = documentDbName;
+ }
+
+ /** Returns the name of the document db this should search, or null if not set. */
+ public String getDocumentDb() { return documentDbName; }
+
+ /** Returns the Execution working on this, or a null execution if none. For internal use. */
+ public Execution getExecution() { return execution; }
+
+ private void setFromString(String string,Set<String> set) {
+ set.clear();
+ for (String item : string.split(","))
+ set.add(item.trim());
+ }
+
+ public static Model getFrom(Query q) {
+ return (Model)q.properties().get(argumentTypeName);
+ }
+
+ public @Override String toString() {
+ return "query representation [queryTree: " + queryTree + ", filter: " + filter + "]";
+ }
+
+ /** Prepares this for binary serialization. For internal use. */
+ public void prepare(Ranking ranking) {
+ prepareRankFeaturesFromModel(ranking);
+ }
+
+ private void prepareRankFeaturesFromModel(Ranking ranking) {
+ Item root = getQueryTree().getRoot();
+ if (root != null) {
+ List<Item> tagged = setUniqueIDs(root);
+ addLabels(tagged, ranking);
+ addConnectivityRankProperties(tagged, ranking);
+ addSignificances(tagged, ranking);
+ }
+ }
+
+ private List<Item> setUniqueIDs(Item root) {
+ List<Item> items = new ArrayList<>();
+ collectTaggableItems(root, items);
+ int id = 1;
+ for (Item i : items) {
+ TaggableItem t = (TaggableItem) i;
+ t.setUniqueID(id++);
+ }
+ return items;
+ }
+
+ private void addLabels(List<Item> candidates, Ranking ranking) {
+ for (Item candidate : candidates) {
+ String label = candidate.getLabel();
+ if (label != null) {
+ String name = "vespa.label." + label + ".id";
+ TaggableItem t = (TaggableItem) candidate;
+ ranking.getProperties().put(name, String.valueOf(t.getUniqueID()));
+ }
+ }
+ }
+
+ private void addConnectivityRankProperties(List<Item> connectedItems, Ranking ranking) {
+ for (Item link : connectedItems) {
+ TaggableItem t = (TaggableItem) link;
+ Item connectedTo = t.getConnectedItem();
+ if (connectedTo != null && strictContains(connectedTo, connectedItems)) {
+ TaggableItem t2 = (TaggableItem) connectedTo;
+ String name = "vespa.term." + t.getUniqueID() + ".connexity";
+ ranking.getProperties().put(name, String.valueOf(t2.getUniqueID()));
+ ranking.getProperties().put(name, String.valueOf(t.getConnectivity()));
+ }
+ }
+ }
+
+ private void addSignificances(List<Item> candidates, Ranking ranking) {
+ for (Item candidate : candidates) {
+ TaggableItem t = (TaggableItem) candidate;
+ if ( ! t.hasExplicitSignificance()) continue;
+ String name = "vespa.term." + t.getUniqueID() + ".significance";
+ ranking.getProperties().put(name, String.valueOf(t.getSignificance()));
+ }
+ }
+
+ private void collectTaggableItems(Item root, List<Item> terms) {
+ if (root == null) return;
+
+ if (root instanceof TaggableItem) {
+ // This is tested before descending, as phrases are viewed
+ // as leaf nodes in the ranking code in the backend
+ terms.add(root);
+ } else if (root instanceof CompositeItem) {
+ CompositeItem c = (CompositeItem) root;
+ for (Iterator<Item> i = c.getItemIterator(); i.hasNext();) {
+ collectTaggableItems(i.next(), terms);
+ }
+ } else {} // nop
+ }
+
+ private boolean strictContains(Object needle, Collection<?> haystack) {
+ for (Object pin : haystack)
+ if (pin == needle) return true;
+ return false;
+ }
+
+
+ /**
+ * Set the YTrace header value to use when transmitting this model to a
+ * search backend (of some kind).
+ *
+ * @param next string representation of header value
+ * @deprecated Not use, ytrace is done
+ */
+ @Deprecated
+ public void setYTraceHeaderToNext(String next) { }
+
+ /**
+ * Get the YTrace header value to use when transmitting this model to a
+ * search backend (of some kind). Returns null if no ytrace data is not
+ * turned on.
+ * @deprecated Not use, ytrace is done
+ */
+ @Deprecated
+ public String getYTraceHeaderToNext() {
+ return null;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/ParameterParser.java b/container-search/src/main/java/com/yahoo/search/query/ParameterParser.java
new file mode 100644
index 00000000000..a27e1bfde55
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/ParameterParser.java
@@ -0,0 +1,88 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import static com.yahoo.container.util.Util.quote;
+
+/**
+ * Wrapper class to avoid code duplication of common parsing requirements.
+ *
+ * @author <a href="steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class ParameterParser {
+
+ /**
+ * Tries to return the given object as a Long. If it is a Number, treat it
+ * as a number of seconds, i.e. get a Long representation and multiply by
+ * 1000. If it has a String representation, try to parse this as a floating
+ * point number, followed by by an optional unit (seconds and an SI prefix,
+ * a couple of valid examples are "s" and "ms". Only a very small subset of
+ * SI prefixes are supported). If no unit is given, seconds are assumed.
+ *
+ * @param value
+ * some representation of a number of seconds
+ * @param defaultValue
+ * returned if value is null
+ * @return value as a number of milliseconds
+ * @throws NumberFormatException
+ * if value is not a Number instance and its String
+ * representation cannot be parsed as a number followed
+ * optionally by time unit
+ */
+ public static Long asMilliSeconds(Object value, Long defaultValue) {
+ if (value == null) {
+ return defaultValue;
+ }
+ if (value instanceof Number) {
+ Number n = (Number) value;
+ return Long.valueOf(n.longValue() * 1000L);
+ }
+ return parseTime(value.toString());
+ }
+
+ private static Long parseTime(String time) throws NumberFormatException {
+
+ time = time.trim();
+ try {
+ int unitOffset = findUnitOffset(time);
+ double measure = Double.valueOf(time.substring(0, unitOffset));
+ double multiplier = parseUnit(time.substring(unitOffset));
+ return Long.valueOf((long) (measure * multiplier));
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException("Error parsing " + quote(time), e);
+ }
+ }
+
+ private static int findUnitOffset(String time) {
+ int unitOffset = 0;
+ while (unitOffset < time.length()) {
+ char c = time.charAt(unitOffset);
+ if (c == '.' || (c >= '0' && c <= '9')) {
+ unitOffset += 1;
+ } else {
+ break;
+ }
+ }
+ if (unitOffset == 0) {
+ throw new NumberFormatException("Invalid number " + quote(time));
+ }
+ return unitOffset;
+ }
+
+ private static double parseUnit(String unit) {
+ unit = unit.trim();
+ final double multiplier;
+ if ("ks".equals(unit)) {
+ multiplier = 1e6d;
+ } else if ("s".equals(unit)) {
+ multiplier = 1000.0d;
+ } else if ("ms".equals(unit)) {
+ multiplier = 1.0d;
+ } else if ("\u00B5s".equals(unit)) {
+ // microseconds
+ multiplier = 1e-3d;
+ } else {
+ multiplier = 1000.0d;
+ }
+ return multiplier;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/Presentation.java b/container-search/src/main/java/com/yahoo/search/query/Presentation.java
new file mode 100644
index 00000000000..466ddf88299
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/Presentation.java
@@ -0,0 +1,211 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.google.common.base.Splitter;
+import com.yahoo.collections.LazySet;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.prelude.query.*;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.rendering.RendererRegistry;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+
+/**
+ * Parameters deciding how the result of a query should be presented
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+public class Presentation implements Cloneable {
+
+ /** The type representing the property arguments consumed by this */
+ private static QueryProfileType argumentType;
+
+ public static final String PRESENTATION = "presentation";
+ public static final String BOLDING = "bolding";
+ public static final String TIMING = "timing";
+ public static final String SUMMARY = "summary";
+ public static final String REPORT_COVERAGE = "reportCoverage";
+ public static final String SUMMARY_FIELDS = "summaryFields";
+
+ /** The (short) name of the parameter holding the name of the return format to use */
+ public static final String FORMAT = "format";
+
+ static {
+ argumentType=new QueryProfileType(PRESENTATION);
+ argumentType.setStrict(true);
+ argumentType.setBuiltin(true);
+ argumentType.addField(new FieldDescription(BOLDING, "boolean", "bolding"));
+ argumentType.addField(new FieldDescription(TIMING, "boolean", "timing"));
+ argumentType.addField(new FieldDescription(SUMMARY, "string", "summary"));
+ argumentType.addField(new FieldDescription(REPORT_COVERAGE, "string", "reportcoverage"));
+ argumentType.addField(new FieldDescription(FORMAT, "string", "format template"));
+ argumentType.addField(new FieldDescription(SUMMARY_FIELDS, "string", "summaryFields"));
+ argumentType.freeze();
+ }
+ public static QueryProfileType getArgumentType() { return argumentType; }
+
+ /** How the result should be highlighted */
+ private Highlight highlight= null;
+
+ /** The terms to highlight in the result (only used by BoldingSearcher, may be removed later). */
+ private List<IndexedItem> boldingData = null;
+
+ /** Whether or not to do highlighting */
+ private boolean bolding = true;
+
+ /** The summary class to be shown */
+ private String summary = null;
+
+ /** Whether coverage information (how much of the indices was searched should be included in the result */
+ private boolean reportCoverage=false;
+
+ /** The name of the renderer to use for rendering the hits. */
+ private ComponentSpecification format = RendererRegistry.defaultRendererId.toSpecification();
+
+ /** Whether optional timing data should be rendered */
+ private boolean timing = false;
+
+ /** Set of explicitly requested summary fields, instead of summary classes */
+ @NonNull
+ private Set<String> summaryFields = LazySet.newHashSet();
+
+ private static final Splitter COMMA_SPLITTER = Splitter.on(',').omitEmptyStrings().trimResults();
+
+ public Presentation(Query parent) { }
+
+ /** Returns how terms in this result should be highlighted, or null if not set */
+ public Highlight getHighlight() { return highlight; }
+
+ /** Sets how terms in this result should be highlighted. Set to null to turn highlighting off */
+ public void setHighlight(Highlight highlight) { this.highlight = highlight; }
+
+ /** Returns the name of the summary class to be used to present hits from this query, or null if not set */
+ public String getSummary() { return summary; }
+
+ /** Sets the name of the summary class to be used to present hits from this query */
+ public void setSummary(String summary) { this.summary = summary; }
+
+ /** Returns whether matching query terms should be bolded in the result. Default is true. */
+ public boolean getBolding() { return bolding; }
+
+ /** Sets whether matching query terms should be bolded in the result */
+ public void setBolding(boolean bolding) { this.bolding = bolding; }
+
+ /** Returns whether coverage information should be returned in the result, if available. Default is false */
+ public boolean getReportCoverage() { return reportCoverage; }
+
+ /** Sets whether coverage information should be returned in the result, if available */
+ public void setReportCoverage(boolean reportCoverage) { this.reportCoverage=reportCoverage; }
+
+ /** Get the name of the format desired for result rendering. */
+ @NonNull
+ public ComponentSpecification getRenderer() { return format; }
+
+ /** Set the desired format for result rendering. If null, use the default renderer. */
+ public void setRenderer(@Nullable ComponentSpecification format) {
+ this.format = (format != null) ? format : RendererRegistry.defaultRendererId.toSpecification();
+ }
+
+ /**
+ * Get the name of the format desired for result rendering.
+ */
+ @NonNull
+ public String getFormat() { return format.getName(); }
+
+ /**
+ * Set the desired format for result rendering. If null, use the default renderer.
+ */
+ public void setFormat(@Nullable String format) {
+ setRenderer(ComponentSpecification.fromString(format));
+ }
+
+ @Override
+ public Object clone() {
+ try {
+ Presentation clone = (Presentation)super.clone();
+ if (boldingData != null)
+ clone.boldingData = new ArrayList<>(boldingData);
+
+ if (highlight != null)
+ clone.highlight = highlight.clone();
+
+ if (summaryFields != null) {
+ clone.summaryFields = LazySet.newHashSet();
+ clone.summaryFields.addAll(this.summaryFields);
+ }
+
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException("Someone inserted a noncloneable superclass",e);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || !(o instanceof Presentation)) return false;
+ Presentation p = (Presentation) o;
+ return QueryHelper.equals(bolding,p.bolding) && QueryHelper.equals(summary,p.summary);
+ }
+
+ @Override
+ public int hashCode() {
+ return QueryHelper.combineHash(bolding, summary);
+ }
+
+ /**
+ * @return whether to add optional timing data to the rendered result
+ */
+ public boolean getTiming() {
+ return timing;
+ }
+
+ public void setTiming(boolean timing) {
+ this.timing = timing;
+ }
+
+ /**
+ * Return the set of explicitly requested fields. Returns an empty set if no
+ * fields are specified outside of summary classes. The returned set is
+ * mutable and fields may be added or removed before passing on the query.
+ *
+ * @return the set of names of requested fields, never null
+ */
+ @NonNull
+ public Set<String> getSummaryFields() {
+ return summaryFields;
+ }
+
+ /** Prepares this for binary serialization. For internal use - see {@link Query#prepare} */
+ public void prepare() {
+ if (highlight != null)
+ highlight.prepare();
+ }
+
+ /**
+ * Parse the given string as a comma delimited set of field names and
+ * overwrite the set of summary fields. Whitespace will be trimmed. If you
+ * want to add or remove fields programmatically, use
+ * {@link #getSummaryFields()} and modify the returned set.
+ *
+ * @param asString
+ * the summary fields requested, e.g. "price,author,title"
+ */
+ public void setSummaryFields(String asString) {
+ summaryFields.clear();
+ for (String field : COMMA_SPLITTER.split(asString)) {
+ summaryFields.add(field);
+ }
+
+ }
+
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/query/Properties.java b/container-search/src/main/java/com/yahoo/search/query/Properties.java
new file mode 100644
index 00000000000..df3d120c337
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/Properties.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.yahoo.search.Query;
+
+/**
+ * Object properties keyed by name which can be looked up using default values and
+ * with conversion to various primitive wrapper types.
+ * <p>
+ * Multiple property implementations can be chained to provide unified access to properties
+ * backed by multiple sources as a Chain of Responsibility.
+ * <p>
+ * For better performance, prefer CompoundName argument constants over Strings.
+ * <p>
+ * Properties can be cloned. Cloning a properties instance returns a new instance
+ * which chains new instances of all chained instances. The content within each instance
+ * is cloned to the extent determined appropriate by that implementation.
+ * <p>
+ * This base class simply passes all access on to the next in chain.
+ *
+ * @author bratseth
+ */
+public abstract class Properties extends com.yahoo.processing.request.Properties {
+
+ @Override
+ public Properties chained() { return (Properties)super.chained(); }
+
+ @Override
+ public Properties clone() {
+ return (Properties)super.clone();
+ }
+
+ /** The query owning this property object.
+ * Only guaranteed to work if this instance is accessible as query.properties()
+ */
+ public Query getParentQuery() {
+ if (chained() == null) {
+ throw new RuntimeException("getParentQuery should only be called on a properties instance accessible as query.properties()");
+ } else {
+ return chained().getParentQuery();
+ }
+ }
+
+ /**
+ * Invoked during deep cloning of the parent query.
+ */
+ public void setParentQuery(Query query) {
+ if (chained() != null)
+ chained().setParentQuery(query);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/QueryHelper.java b/container-search/src/main/java/com/yahoo/search/query/QueryHelper.java
new file mode 100644
index 00000000000..d4b6f257c11
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/QueryHelper.java
@@ -0,0 +1,27 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+/**
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+class QueryHelper {
+
+ /** Compares two objects which may be null */
+ public static boolean equals(Object a,Object b) {
+ if (a == null) return b == null;
+ return a.equals(b);
+ }
+
+ /**
+ * Helper method that finds the hashcode for a group of objects.
+ * Inspired by java.util.List
+ */
+ public static int combineHash(Object... objs) {
+ int hash = 1;
+ for (Object o:objs) {
+ hash = 31*hash + (o == null ? 0 : o.hashCode());
+ }
+ return hash;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/QueryTree.java b/container-search/src/main/java/com/yahoo/search/query/QueryTree.java
new file mode 100644
index 00000000000..3a501853388
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/QueryTree.java
@@ -0,0 +1,159 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.yahoo.prelude.query.*;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * The root node of a query tree. This is always present above the actual semantic root to ease query manipulation,
+ * especially replacing the actual semantic root, but does not have any search semantics on its own.
+ *
+ * <p>To ease recursive manipulation of the query tree, this is a composite having one child, which is the actual root.
+ * <ul>
+ * <li>Setting the root item (at position 0, either directly or though the iterator of this, works as expected.
+ * Setting at any other position is disallowed.
+ * <li>Removing the root is allowed and causes this to be a null query.
+ * <li>Adding an item is only allowed if this is currently a null query (having no root)
+ * </ul>
+ *
+ * <p>This is also the home of accessor methods which eases querying into and manipulation of the query tree.</p>
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+public class QueryTree extends CompositeItem {
+
+ public QueryTree(Item root) {
+ setRoot(root);
+ }
+
+ public void setIndexName(String index) {
+ if (getRoot() != null)
+ getRoot().setIndexName(index);
+ }
+
+ public ItemType getItemType() {
+ throw new RuntimeException("Packet type access attempted. " +
+ "A query tree has no packet code. This is probably a misbehaving searcher.");
+ }
+
+ public String getName() { return "ROOT"; }
+
+ public int encode(ByteBuffer buffer) {
+ if (getRoot() == null) return 0;
+ return getRoot().encode(buffer);
+ }
+
+ //Lets not pollute toString() by adding "ROOT"
+ protected void appendHeadingString(StringBuilder sb) {
+ }
+
+ /** Returns the query root. This is null if this is a null query. */
+ public Item getRoot() {
+ if (getItemCount()==0) return null;
+ return getItem(0);
+ }
+
+ public final void setRoot(Item root) {
+ if (root==this) throw new IllegalArgumentException("Cannot make a root point at itself");
+ if (root == null) throw new IllegalArgumentException("Root must not be null, use NullItem instead.");
+ if (root instanceof QueryTree) throw new IllegalArgumentException("Do not use a new QueryTree instance as a root.");
+ if (this.getItemCount()==0) // initializing
+ super.addItem(root);
+ else
+ setItem(0,root); // replacing
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if( !(o instanceof QueryTree)) return false;
+ return super.equals(o);
+ }
+
+ /** Returns a deep copy of this */
+ @Override
+ public QueryTree clone() {
+ QueryTree clone = (QueryTree) super.clone();
+ fixClonedConnectivityReferences(clone);
+ return clone;
+ }
+
+ private void fixClonedConnectivityReferences(QueryTree clone) {
+ // TODO!
+ }
+
+ @Override
+ public void addItem(Item item) {
+ if (getItemCount()==0)
+ super.addItem(item);
+ else
+ throw new RuntimeException("Programming error: Cannot add multiple roots");
+ }
+
+ @Override
+ public void addItem(int index, Item item) {
+ if (getItemCount()==0 && index==0)
+ super.addItem(index,item);
+ else
+ throw new RuntimeException("Programming error: Cannot add multiple roots, have '" + getRoot() + "'");
+ }
+
+ /** Returns true if this represents the null query */
+ public boolean isEmpty() {
+ return getRoot() instanceof NullItem;
+ }
+
+ // -------------- Facade
+
+ /** Modifies this query to become the current query AND the given item */
+ // TODO: Make sure this is complete, unit test and make it public
+ private void and(Item item) {
+ if (isEmpty()) {
+ setRoot(item);
+ }
+ else if (getRoot() instanceof NotItem && item instanceof NotItem) {
+ throw new IllegalArgumentException("Can't AND two NOTs"); // TODO: Complete
+ }
+ else if (getRoot() instanceof NotItem){
+ NotItem notItem = (NotItem)getRoot();
+ notItem.addPositiveItem(item);
+ }
+ else if (item instanceof NotItem){
+ NotItem notItem = (NotItem)item;
+ notItem.addPositiveItem(getRoot());
+ setRoot(notItem);
+ }
+ else {
+ AndItem andItem = new AndItem();
+ andItem.addItem(getRoot());
+ andItem.addItem(item);
+ setRoot(andItem);
+ }
+ }
+
+ /** Returns a flattened list of all positive query terms under the given item */
+ public static List<IndexedItem> getPositiveTerms(Item item) {
+ List<IndexedItem> items = new ArrayList<>();
+ getPositiveTerms(item,items);
+ return items;
+ }
+
+ private static void getPositiveTerms(Item item, List<IndexedItem> terms) {
+ if (item instanceof NotItem) {
+ getPositiveTerms(((NotItem) item).getPositiveItem(), terms);
+ } else if (item instanceof PhraseItem) {
+ PhraseItem pItem = (PhraseItem)item;
+ terms.add(pItem);
+ } else if (item instanceof CompositeItem) {
+ for (Iterator<Item> i = ((CompositeItem) item).getItemIterator(); i.hasNext();) {
+ getPositiveTerms(i.next(), terms);
+ }
+ } else if (item instanceof TermItem) {
+ terms.add((TermItem)item);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/Ranking.java b/container-search/src/main/java/com/yahoo/search/query/Ranking.java
new file mode 100644
index 00000000000..e543589f74d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/Ranking.java
@@ -0,0 +1,246 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.prelude.Freshness;
+import com.yahoo.prelude.Location;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.ranking.MatchPhase;
+import com.yahoo.search.query.ranking.RankFeatures;
+import com.yahoo.search.query.ranking.RankProperties;
+import com.yahoo.search.result.ErrorMessage;
+
+/**
+ * The ranking (hit ordering) settings of a query
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ * @author bratseth
+ */
+public class Ranking implements Cloneable {
+
+ /** An alias for listing features */
+ public static final com.yahoo.processing.request.CompoundName RANKFEATURES =
+ new com.yahoo.processing.request.CompoundName("rankfeatures");
+
+ /** The type representing the property arguments consumed by this */
+ private static final QueryProfileType argumentType;
+ private static final CompoundName argumentTypeName;
+
+ public static final String RANKING = "ranking";
+ public static final String LOCATION = "location";
+ public static final String PROFILE = "profile";
+ public static final String SORTING = "sorting";
+ public static final String LIST_FEATURES = "listFeatures";
+ public static final String FRESHNESS = "freshness";
+ public static final String QUERYCACHE = "queryCache";
+ public static final String MATCH_PHASE = "matchPhase";
+ public static final String DIVERSITY = "diversity";
+ public static final String FEATURES = "features";
+ public static final String PROPERTIES = "properties";
+
+ static {
+ argumentType =new QueryProfileType(RANKING);
+ argumentType.setStrict(true);
+ argumentType.setBuiltin(true);
+ argumentType.addField(new FieldDescription(LOCATION, "string", "location"));
+ argumentType.addField(new FieldDescription(PROFILE, "string", "ranking"));
+ argumentType.addField(new FieldDescription(SORTING, "string", "sorting sortspec"));
+ argumentType.addField(new FieldDescription(LIST_FEATURES, "string", RANKFEATURES.toString()));
+ argumentType.addField(new FieldDescription(FRESHNESS, "string", "datetime"));
+ argumentType.addField(new FieldDescription(QUERYCACHE, "string"));
+ argumentType.addField(new FieldDescription(MATCH_PHASE, "query-profile", "matchPhase"));
+ argumentType.addField(new FieldDescription(FEATURES, "query-profile", "rankfeature"));
+ argumentType.addField(new FieldDescription(PROPERTIES, "query-profile", "rankproperty"));
+ argumentType.freeze();
+ argumentTypeName=new CompoundName(argumentType.getId().getName());
+ }
+ public static QueryProfileType getArgumentType() { return argumentType; }
+
+ private Query parent;
+
+ /** The location of the query is used for distance ranking */
+ private Location location = null;
+
+ /** The name of the rank profile to use */
+ private String profile = null;
+
+ /** How the query should be sorted */
+ private Sorting sorting = null;
+
+ /** Set to true to include the value of "all" rank features in the result */
+ private boolean listFeatures = false;
+
+ private Freshness freshness;
+
+ private boolean queryCache = false;
+
+ private RankProperties rankProperties = new RankProperties();
+
+ private RankFeatures rankFeatures = new RankFeatures();
+
+ private MatchPhase matchPhase = new MatchPhase();
+
+ public Ranking(Query parent) {
+ this.parent = parent;
+ }
+
+ /**
+ * Returns whether a rank profile has been explicitly set.
+ *
+ * This is only used in serializing the packet properly to FS4.
+ */
+ public boolean hasRankProfile() {
+ return profile != null;
+ }
+
+ /** Get the freshness search parameters associated with this query */
+ public Freshness getFreshness() {
+ return freshness;
+ }
+
+ /** Set the freshness search parameters for this query */
+ public void setFreshness(String dateTime) {
+ try {
+ Freshness freshness = new Freshness(dateTime);
+ setFreshness(freshness);
+ } catch (NumberFormatException e) {
+ parent.errors().add(ErrorMessage.createInvalidQueryParameter("Datetime reference could not be converted from '"
+ + dateTime + "' to long"));
+ }
+ }
+
+ public void setFreshness(Freshness freshness) {
+ this.freshness = freshness;
+ }
+
+ /**
+ * Returns whether feature caching is turned on in the backed.
+ * Feature caching allows us to avoid sending the query during document summary retrieval
+ * and recalculate feature scores, it is typically beneficial to turn it on if
+ * fan-out is low or queries are large.
+ * <p>
+ * Default is false (off).
+ */
+ public void setQueryCache(boolean queryCache) { this.queryCache = queryCache; }
+
+ public boolean getQueryCache() { return queryCache; }
+
+ /** Returns the location of this query, or null if none */
+ public Location getLocation() { return location; }
+
+ public void setLocation(Location location) { this.location = location; }
+
+ /** Sets the location from a string, see {@link Location} for syntax */
+ public void setLocation(String str) { this.location = new Location(str); }
+
+ /** Returns the name of the rank profile to be used. Returns "default" if nothing is set. */
+ public String getProfile() { return profile == null ? "default" : profile; }
+
+ /** Sets the name of the rank profile to use. This cannot be set to null. */
+ public void setProfile(String profile) {
+ if (profile==null) throw new NullPointerException("The ranking profile cannot be set to null");
+ this.profile = profile;
+ }
+
+ /**
+ * Returns the rank features of this, an empty container (never null) if none are set.
+ * The returned object can be modified directly to change the rank properties of this.
+ */
+ public RankFeatures getFeatures() {
+ return rankFeatures;
+ }
+
+ /**
+ * Returns the rank properties of this, an empty container (never null) if none are set.
+ * The returned object can be modified directly to change the rank properties of this.
+ */
+ public RankProperties getProperties() {
+ return rankProperties;
+ }
+
+ /** Set whether rank features should be included with the result of this query */
+ public void setListFeatures(boolean listFeatures) { this.listFeatures = listFeatures; }
+
+ /** Returns whether rank features should be dumped with the result of this query, default false */
+ public boolean getListFeatures() { return listFeatures; }
+
+ /** Returns the match phase rank settings of this. This is never null. */
+ public MatchPhase getMatchPhase() { return matchPhase; }
+
+ @Override
+ public Object clone() {
+ try {
+ Ranking clone = (Ranking) super.clone();
+
+ if (sorting != null) clone.sorting = this.sorting.clone();
+
+ clone.rankProperties = this.rankProperties.clone();
+ clone.rankFeatures = this.rankFeatures.clone();
+ clone.matchPhase = this.matchPhase.clone();
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException("Someone inserted a noncloneable superclass",e);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if( ! (o instanceof Ranking)) return false;
+
+ Ranking other = (Ranking) o;
+
+ if ( ! QueryHelper.equals(rankProperties, other.rankProperties)) return false;
+ if ( ! QueryHelper.equals(rankFeatures, other.rankFeatures)) return false;
+ if ( ! QueryHelper.equals(freshness, other.freshness)) return false;
+ if ( ! QueryHelper.equals(this.sorting, other.sorting)) return false;
+ if ( ! QueryHelper.equals(this.location, other.location)) return false;
+ if ( ! QueryHelper.equals(this.profile, other.profile)) return false;
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 0;
+ hash += 11 * rankFeatures.hashCode();
+ hash += 13 * rankProperties.hashCode();
+ hash += 17 * matchPhase.hashCode();
+ return Ranking.class.hashCode() + QueryHelper.combineHash(sorting,location,profile,hash);
+ }
+
+ /** Returns the sorting spec of this query, or null if none is set */
+ public Sorting getSorting() { return sorting; }
+
+ /** Sets how this query should be sorted. Set to null to turn off explicit sorting. */
+ public void setSorting(Sorting sorting) { this.sorting = sorting; }
+
+ /** Sets sorting from a string. See {@link Sorting} on syntax */
+ public void setSorting(String sortingString) {
+ if (sortingString==null)
+ setSorting((Sorting)null);
+ else
+ setSorting(new Sorting(sortingString));
+ }
+
+ public static Ranking getFrom(Query q) {
+ return (Ranking) q.properties().get(argumentTypeName);
+ }
+
+ public void prepare() {
+ rankFeatures.prepare(rankProperties);
+ matchPhase.prepare(rankProperties);
+ prepareNow(freshness);
+ }
+
+ private void prepareNow(Freshness freshness) {
+ if (freshness == null) return;
+ // TODO: See what freshness is doing with the internal props and simplify
+ if (rankProperties.get("vespa.now") == null || rankProperties.get("vespa.now").isEmpty()) {
+ rankProperties.put("vespa.now", "" + freshness.getRefTime());
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/SessionId.java b/container-search/src/main/java/com/yahoo/search/query/SessionId.java
new file mode 100644
index 00000000000..7f8ca6385e1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/SessionId.java
@@ -0,0 +1,36 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.yahoo.container.Server;
+import com.yahoo.text.Utf8String;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A query id which is unique across this cluster - consisting of
+ * container runtime id + timestamp + serial.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class SessionId {
+
+ private static final String serverId = Server.get().getServerDiscriminator();
+ private static final AtomicLong sequenceCounter = new AtomicLong();
+
+ private final Utf8String id;
+
+ private SessionId(String serverId, long timestamp, long sequence) {
+ this.id = new Utf8String(serverId + "." + timestamp + "." + sequence);
+ }
+
+ public Utf8String asUtf8String() { return id; }
+
+ /**
+ * Creates a session id which is unique across the cluster this runtime is a member of each time this is called.
+ * Calling this causes synchronization.
+ */
+ public static SessionId next() {
+ return new SessionId(serverId, System.currentTimeMillis(), sequenceCounter.getAndIncrement());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/Sorting.java b/container-search/src/main/java/com/yahoo/search/query/Sorting.java
new file mode 100644
index 00000000000..3af9bc34940
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/Sorting.java
@@ -0,0 +1,407 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query;
+
+import com.ibm.icu.text.Collator;
+import com.ibm.icu.util.ULocale;
+import com.yahoo.text.Utf8;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+
+/**
+ * Specifies how a query is sorted by a list of fields with a sort order
+ *
+ * @author Arne Bergene Fossaa
+ */
+public class Sorting implements Cloneable {
+
+ public static final String STRENGTH_IDENTICAL = "identical";
+ public static final String STRENGTH_QUATERNARY = "quaternary";
+ public static final String STRENGTH_TERTIARY = "tertiary";
+ public static final String STRENGTH_SECONDARY = "secondary";
+ public static final String STRENGTH_PRIMARY = "primary";
+ public static final String UCA = "uca";
+ public static final String RAW = "raw";
+ public static final String LOWERCASE = "lowercase";
+
+ private final List<FieldOrder> fieldOrders = new ArrayList<>(2);
+
+ /** Creates an empty sort spec */
+ public Sorting() { }
+
+ public Sorting(List<FieldOrder> fieldOrders) {
+ this.fieldOrders.addAll(fieldOrders);
+ }
+
+ /** Creates a sort spec from a string */
+ public Sorting(String sortSpec) {
+ setSpec(sortSpec);
+ }
+
+ /**
+ * Creates a new sorting from the given string and returns it, or returns null if the argument does not contain
+ * any sorting criteria (e.g it is null or the empty string)
+ */
+ public static Sorting fromString(String sortSpec) {
+ if (sortSpec==null) return null;
+ if ("".equals(sortSpec)) return null;
+ return new Sorting(sortSpec);
+ }
+
+ private void setSpec(String rawSortSpec) {
+ String[] vectors = rawSortSpec.split(" ");
+
+ for (String sortString:vectors) {
+ // A sortspec element must be at least two characters long,
+ // a sorting order and an attribute vector name
+ if (sortString.length() < 1) {
+ continue;
+ }
+ char orderMarker = sortString.charAt(0);
+ int funcAttrStart = 0;
+ if ((orderMarker == '+') || (orderMarker == '-')) {
+ funcAttrStart = 1;
+ }
+ AttributeSorter sorter = null;
+ int startPar = sortString.indexOf('(',funcAttrStart);
+ int endPar = sortString.lastIndexOf(')');
+ if ((startPar > 0) && (endPar > startPar)) {
+ String funcName = sortString.substring(funcAttrStart, startPar);
+ if (LOWERCASE.equalsIgnoreCase(funcName)) {
+ sorter = new LowerCaseSorter(sortString.substring(startPar+1, endPar));
+ } else if (RAW.equalsIgnoreCase(funcName)) {
+ sorter = new RawSorter(sortString.substring(startPar+1, endPar));
+ } else if (UCA.equalsIgnoreCase(funcName)) {
+ int commaPos = sortString.indexOf(',', startPar+1);
+ if ((startPar+1 < commaPos) && (commaPos < endPar)) {
+ int commaopt = sortString.indexOf(',', commaPos + 1);
+ UcaSorter.Strength strength = UcaSorter.Strength.UNDEFINED;
+ if (commaopt > 0) {
+ String s = sortString.substring(commaopt+1, endPar);
+ if (STRENGTH_PRIMARY.equalsIgnoreCase(s)) {
+ strength = UcaSorter.Strength.PRIMARY;
+ } else if (STRENGTH_SECONDARY.equalsIgnoreCase(s)) {
+ strength = UcaSorter.Strength.SECONDARY;
+ } else if (STRENGTH_TERTIARY.equalsIgnoreCase(s)) {
+ strength = UcaSorter.Strength.TERTIARY;
+ } else if (STRENGTH_QUATERNARY.equalsIgnoreCase(s)) {
+ strength = UcaSorter.Strength.QUATERNARY;
+ } else if (STRENGTH_IDENTICAL.equalsIgnoreCase(s)) {
+ strength = UcaSorter.Strength.IDENTICAL;
+ } else {
+ throw new IllegalArgumentException("Unknown collation strength: '" + s + "'");
+ }
+ sorter = new UcaSorter(sortString.substring(startPar+1, commaPos), sortString.substring(commaPos+1, commaopt), strength);
+ } else {
+ sorter = new UcaSorter(sortString.substring(startPar+1, commaPos), sortString.substring(commaPos+1, endPar), strength);
+ }
+ } else {
+ sorter = new UcaSorter(sortString.substring(startPar+1, endPar));
+ }
+ } else {
+ if (funcName.isEmpty()) {
+ throw new IllegalArgumentException("No sort function specified");
+ } else {
+ throw new IllegalArgumentException("Unknown sort function '" + funcName + "'");
+ }
+ }
+ } else {
+ sorter = new AttributeSorter(sortString.substring(funcAttrStart));
+ }
+ Order order = Order.UNDEFINED;
+ if (funcAttrStart != 0) {
+ // Override in sortspec
+ order = (orderMarker == '+') ? Order.ASCENDING : Order.DESCENDING;
+ }
+ fieldOrders.add(new FieldOrder(sorter, order));
+ }
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+
+ String space = "";
+ for (FieldOrder spec : fieldOrders) {
+ sb.append(space);
+ if (spec.getSortOrder() == Order.DESCENDING) {
+ sb.append("-");
+ } else {
+ sb.append("+");
+ }
+ sb.append(spec.getFieldName());
+ space = " ";
+ }
+ return sb.toString();
+ }
+
+
+ public enum Order {ASCENDING,DESCENDING,UNDEFINED}
+
+ /**
+ * Returns the field orders of this sort specification as list. This is never null but can be empty.
+ * This list can be modified to change this sort spec.
+ */
+ public List<FieldOrder> fieldOrders() { return fieldOrders; }
+
+ public Sorting clone() {
+ return new Sorting(this.fieldOrders);
+ }
+
+ public static class AttributeSorter implements Cloneable {
+ private static final Pattern legalAttributeName = Pattern.compile("[\\[]*[a-zA-Z_][\\.a-zA-Z0-9_-]*[\\]]*");
+
+ private String fieldName;
+ public AttributeSorter(String fieldName) {
+ if (legalAttributeName.matcher(fieldName).matches()) {
+ this.fieldName = fieldName;
+ } else {
+ throw new IllegalArgumentException("Illegal attribute name '" + fieldName + "' for sorting. Requires '" + legalAttributeName.pattern() + "'");
+ }
+ }
+ public String getName() { return fieldName; }
+ public void setName(String fieldName) { this.fieldName = fieldName; }
+ @Override
+ public String toString() { return fieldName; }
+ @Override
+ public int hashCode() { return fieldName.hashCode(); }
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof AttributeSorter)) {
+ return false;
+ }
+ return ((AttributeSorter) other).fieldName.equals(fieldName);
+ }
+ @Override
+ public AttributeSorter clone() {
+ try {
+ return (AttributeSorter)super.clone();
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+
+ }
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ public int compare(Comparable a, Comparable b) {
+ return a.compareTo(b);
+ }
+
+ }
+ public static class RawSorter extends AttributeSorter
+ {
+ public RawSorter(String fieldName) { super(fieldName); }
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof RawSorter)) {
+ return false;
+ }
+ return super.equals(other);
+ }
+ }
+ public static class LowerCaseSorter extends AttributeSorter
+ {
+ public LowerCaseSorter(String fieldName) { super(fieldName); }
+ @Override
+ public String toString() { return "lowercase(" + getName() + ')'; }
+ @Override
+ public int hashCode() { return 1 + 3*super.hashCode(); }
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof LowerCaseSorter)) {
+ return false;
+ }
+ return super.equals(other);
+ }
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ public int compare(Comparable a, Comparable b) {
+ if ((a instanceof String) && (b instanceof String)) {
+ return ((String)a).compareToIgnoreCase((String) b);
+ }
+ return a.compareTo(b);
+ }
+ }
+ public static class UcaSorter extends AttributeSorter
+ {
+ public enum Strength { PRIMARY, SECONDARY, TERTIARY, QUATERNARY, IDENTICAL, UNDEFINED };
+ private String locale = null;
+ private Strength strength = Strength.UNDEFINED;
+ private Collator collator;
+ public UcaSorter(String fieldName, String locale, Strength strength) { super(fieldName); setLocale(locale, strength); }
+ public UcaSorter(String fieldName) { super(fieldName); }
+ static private int strength2Collator(Strength strength) {
+ switch (strength) {
+ case PRIMARY: return Collator.PRIMARY;
+ case SECONDARY: return Collator.SECONDARY;
+ case TERTIARY: return Collator.TERTIARY;
+ case QUATERNARY: return Collator.QUATERNARY;
+ case IDENTICAL: return Collator.IDENTICAL;
+ case UNDEFINED: return Collator.PRIMARY;
+ }
+ return Collator.PRIMARY;
+ }
+ public void setLocale(String locale, Strength strength) {
+ this.locale = locale;
+ this.strength = strength;
+ ULocale uloc;
+ try {
+ uloc = new ULocale(locale);
+ } catch (Throwable e) {
+ throw new RuntimeException("ULocale("+locale+") failed with exception " + e.toString());
+ }
+ try {
+ collator = Collator.getInstance(uloc);
+ if (collator == null) {
+ throw new RuntimeException("No collator available for: " + locale);
+ }
+ } catch (Throwable e) {
+ throw new RuntimeException("Collator.getInstance(ULocale("+locale+")) failed with exception " + e.toString());
+ }
+ collator.setStrength(strength2Collator(strength));
+ // collator.setDecomposition(Collator.CANONICAL_DECOMPOSITION);
+ }
+ public String getLocale() { return locale; }
+ public Strength getStrength() { return strength; }
+ public Collator getCollator() { return collator; }
+ public String getDecomposition() { return (collator.getDecomposition() == Collator.CANONICAL_DECOMPOSITION) ? "CANONICAL_DECOMPOSITION" : "NO_DECOMPOSITION"; }
+ @Override
+ public String toString() { return "uca(" + getName() + ',' + locale + ',' + ((strength != Strength.UNDEFINED) ? strength.toString() : "PRIMARY") + ')'; }
+ @Override
+ public int hashCode() { return 1 + 3*locale.hashCode() + 5*strength.hashCode() + 7*super.hashCode(); }
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof UcaSorter)) {
+ return false;
+ }
+ return super.equals(other) && locale.equals(((UcaSorter)other).locale) && (strength == ((UcaSorter)other).strength);
+ }
+ public UcaSorter clone() {
+ UcaSorter clone = (UcaSorter)super.clone();
+ if (locale != null) {
+ clone.setLocale(locale, strength);
+ }
+ return clone;
+ }
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ public int compare(Comparable a, Comparable b) {
+ if ((a instanceof String) && (b instanceof String)) {
+ return collator.compare((String)a, (String) b);
+ }
+ return a.compareTo(b);
+ }
+ }
+ /**
+ * An attribute (field) and how it should be sorted
+ */
+ public static class FieldOrder implements Cloneable {
+
+ private AttributeSorter fieldSorter;
+ private Order sortOrder;
+
+ /**
+ * Creates an attribute vector
+ *
+ * @param fieldSorter the sorter of this attribute
+ * @param sortOrder whether to sort this ascending or descending
+ */
+ public FieldOrder(AttributeSorter fieldSorter, Order sortOrder) {
+ this.fieldSorter = fieldSorter;
+ this.sortOrder = sortOrder;
+ }
+
+ /**
+ * Returns the name of this attribute
+ */
+ public String getFieldName() {
+ return fieldSorter.getName();
+ }
+
+ /**
+ * Returns the sorter of this attribute
+ */
+ public AttributeSorter getSorter() { return fieldSorter; }
+ public void setSorter(AttributeSorter sorter) { fieldSorter = sorter; }
+
+ /**
+ * Returns the sorting order of this attribute
+ */
+ public Order getSortOrder() {
+ return sortOrder;
+ }
+
+ /**
+ * Decide if sortorder is ascending or not.
+ */
+ public void setAscending(boolean asc) {
+ sortOrder = asc ? Order.ASCENDING : Order.DESCENDING;
+ }
+
+ @Override
+ public String toString() {
+ return sortOrder.toString() + ":" + fieldSorter.toString();
+ }
+
+ @Override
+ public int hashCode() {
+ return sortOrder.hashCode() + 17 * fieldSorter.hashCode();
+ }
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof FieldOrder)) {
+ return false;
+ }
+ FieldOrder otherAttr = (FieldOrder) other;
+
+ return otherAttr.sortOrder.equals(sortOrder)
+ && otherAttr.fieldSorter.equals(fieldSorter);
+ }
+ @Override
+ public FieldOrder clone() {
+ return new FieldOrder(fieldSorter.clone(), sortOrder);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return fieldOrders.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if( ! (o instanceof Sorting)) return false;
+
+ Sorting ss = (Sorting) o;
+ return fieldOrders.equals(ss.fieldOrders);
+ }
+
+ public int encode(ByteBuffer buffer) {
+ int usedBytes = 0;
+ byte[] nameBuffer;
+ buffer.position();
+ byte space = '.';
+ for (FieldOrder fieldOrder : fieldOrders) {
+ if (space == ' ') {
+ buffer.put(space);
+ usedBytes++;
+ }
+ if (fieldOrder.getSortOrder() == Order.ASCENDING) {
+ buffer.put((byte) '+');
+ } else {
+ buffer.put((byte) '-');
+ }
+ usedBytes++;
+ nameBuffer = Utf8.toBytes(fieldOrder.getSorter().toString());
+ buffer.put(nameBuffer);
+ usedBytes += nameBuffer.length;
+ // If this isn't the last element, append a separating space
+ //if (i + 1 < sortSpec.size()) {
+ space = ' ';
+ }
+ return usedBytes;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java b/container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java
new file mode 100644
index 00000000000..e59f8589903
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java
@@ -0,0 +1,112 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.context;
+
+import com.yahoo.processing.execution.Execution;
+import com.yahoo.search.Query;
+import com.yahoo.search.rendering.DefaultRenderer;
+import com.yahoo.text.XMLWriter;
+import com.yahoo.yolean.trace.TraceNode;
+
+import java.io.Writer;
+import java.util.Iterator;
+
+
+/**
+ * A proxy to the Execution.trace() which exists for legacy reasons.
+ * Calls to this is forwarded to owningQuery.getModel().getExecution().trace().
+ *
+ * @since 4.2
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class QueryContext implements Cloneable {
+
+ public static final String ID = "context";
+ private Query owner;
+
+ public QueryContext(int ignored,Query owner) {
+ this.owner=owner;
+ }
+
+ //---------------- Public API ---------------------------------------------------------------------------------
+
+ /** Adds a context message to this context */
+ public void trace(String message, int traceLevel) {
+ owner.getModel().getExecution().trace().trace(message,traceLevel);
+ }
+
+ /**
+ * Adds a key-value which will be logged to the access log for this query (by doing toString() on the value
+ * Multiple values may be set to the same key. A value cannot be removed once set.
+ */
+ public void logValue(String key,Object value) {
+ owner.getModel().getExecution().trace().logValue(key, value.toString());
+ }
+
+ /** Returns the values to be written to the access log for this */
+ public Iterator<Execution.Trace.LogValue> logValueIterator() {
+ return owner.getModel().getExecution().trace().logValueIterator();
+ }
+
+ /**
+ * Adds a property key-value to this context.
+ * If the same name is set multiple times, the behavior is thus:
+ * <ul>
+ * <li>Within a single context (thread/query clone), the last value set is used</li>
+ * <li>Across multiple traces, the <i>last</i> value from the <i>last</i> deepest nested thread/clone is used.
+ * In the case of multiple threads writing the value concurrently to their clone, it is of course undefined
+ * which one will be used.</li>
+ * </ul>
+ *
+ * @param name the name of the property
+ * @param value the value of the property, or null to set this property to null
+ */
+ public void setProperty(String name,Object value) {
+ owner.getModel().getExecution().trace().setProperty(name,value);
+ }
+
+ /**
+ * Returns a property set anywhere in this context.
+ * Note that even though this call is itself "thread robust", the object values returned
+ * may in some scenarios not be written behind a synchronization barrier, so when accessing
+ * objects which are not inherently thread safe, synchronization should be considered.
+ * <p>
+ * Note that this method have a time complexity which is proportional to
+ * the number of cloned/created queries times the average number of properties in each.
+ */
+ public Object getProperty(String name) {
+ return owner.getModel().getExecution().trace().getProperty(name);
+ }
+
+ /** Returns a short string description of this (includes the first few messages only, and no newlines) */
+ @Override
+ public String toString() {
+ return owner.getModel().getExecution().trace().toString();
+ }
+
+ public boolean render(Writer writer) throws java.io.IOException {
+ if (owner.getTraceLevel()!=0) {
+ XMLWriter xmlWriter=XMLWriter.from(writer);
+ xmlWriter.openTag("meta").attribute("type",ID);
+ TraceNode traceRoot=owner.getModel().getExecution().trace().traceNode().root();
+ traceRoot.accept(new DefaultRenderer.RenderingVisitor(xmlWriter,owner.getStartTime()));
+ xmlWriter.closeTag();
+ }
+ return true;
+ }
+
+ public QueryContext cloneFor(Query cloneOwner) {
+ try {
+ QueryContext clone=(QueryContext)super.clone();
+ clone.owner=cloneOwner;
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Returns the execution trace this delegates to */
+ public Execution.Trace getTrace() { return owner.getModel().getExecution().trace(); }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/context/package-info.java b/container-search/src/main/java/com/yahoo/search/query/context/package-info.java
new file mode 100644
index 00000000000..c19e5abedd0
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/context/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.context;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/package-info.java b/container-search/src/main/java/com/yahoo/search/query/package-info.java
new file mode 100644
index 00000000000..2384169c52b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * The search query model
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java b/container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java
new file mode 100644
index 00000000000..92601a5464d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java
@@ -0,0 +1,112 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.parser;
+
+import com.yahoo.language.Language;
+import com.yahoo.search.query.Model;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * <p>This class encapsulates all the parameters required to call {@link Parser#parse(Parsable)}. Because all set-
+ * methods return a reference to self, you can write very compact calls to the parser:</p>
+ *
+ * <pre>
+ * parser.parse(new Parsable()
+ * .setQuery("foo")
+ * .setFilter("bar")
+ * .setDefaultIndexName("default")
+ * .setLanguage(Language.ENGLISH))
+ * </pre>
+ *
+ * <p>In case you are parsing the content of a {@link Model}, you can use the {@link #fromQueryModel(Model)} factory for
+ * convenience.</p>
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ * @since 5.1.4
+ */
+public final class Parsable {
+
+ private final Set<String> sourceList = new HashSet<>();
+ private final Set<String> restrictList = new HashSet<>();
+ private String query;
+ private String filter;
+ private String defaultIndexName;
+ private Language language;
+
+ public String getQuery() {
+ return query;
+ }
+
+ public Parsable setQuery(String query) {
+ this.query = query;
+ return this;
+ }
+
+ public String getFilter() {
+ return filter;
+ }
+
+ public Parsable setFilter(String filter) {
+ this.filter = filter;
+ return this;
+ }
+
+ public String getDefaultIndexName() {
+ return defaultIndexName;
+ }
+
+ public Parsable setDefaultIndexName(String defaultIndexName) {
+ this.defaultIndexName = defaultIndexName;
+ return this;
+ }
+
+ public Language getLanguage() {
+ return language;
+ }
+
+ public Parsable setLanguage(Language language) {
+ this.language = language;
+ return this;
+ }
+
+ public Set<String> getSources() {
+ return sourceList;
+ }
+
+ public Parsable addSource(String sourceName) {
+ sourceList.add(sourceName);
+ return this;
+ }
+
+ public Parsable addSources(Collection<String> sourceNames) {
+ sourceList.addAll(sourceNames);
+ return this;
+ }
+
+ public Set<String> getRestrict() {
+ return restrictList;
+ }
+
+ public Parsable addRestrict(String restrictName) {
+ restrictList.add(restrictName);
+ return this;
+ }
+
+ public Parsable addRestricts(Collection<String> restrictNames) {
+ restrictList.addAll(restrictNames);
+ return this;
+ }
+
+ public static Parsable fromQueryModel(Model model) {
+ return new Parsable()
+ .setQuery(model.getQueryString())
+ .setFilter(model.getFilter())
+ .setLanguage(model.getParsingLanguage())
+ .setDefaultIndexName(model.getDefaultIndex())
+ .addSources(model.getSources())
+ .addRestricts(model.getRestrict());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/Parser.java b/container-search/src/main/java/com/yahoo/search/query/parser/Parser.java
new file mode 100644
index 00000000000..3822b9b67d8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/parser/Parser.java
@@ -0,0 +1,24 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.parser;
+
+import com.yahoo.search.query.QueryTree;
+
+/**
+ * Defines the interface of a query parser. To construct an instance of this class, use the {@link ParserFactory}.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+public interface Parser {
+
+ /**
+ * Parser the given {@link Parsable}, and returns a corresponding
+ * {@link QueryTree}. If parsing fails without an exception, the contained
+ * root will be an instance of {@link com.yahoo.prelude.query.NullItem}.
+ *
+ * @param query
+ * the Parsable to parse
+ * @return the parsed QueryTree, never null
+ */
+ QueryTree parse(Parsable query);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java b/container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java
new file mode 100644
index 00000000000..b00afa27bf6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java
@@ -0,0 +1,76 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.parser;
+
+import com.yahoo.language.Linguistics;
+import com.yahoo.language.simple.SimpleLinguistics;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.parser.SpecialTokenRegistry;
+import com.yahoo.prelude.query.parser.SpecialTokens;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * This class encapsulates the environment of a {@link Parser}. In case you are creating a parser from within a
+ * {@link Searcher}, you can use the {@link #fromExecutionContext(Execution.Context)} factory for convenience.
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ * @since 5.1.4
+ */
+public final class ParserEnvironment {
+
+ private IndexFacts indexFacts = new IndexFacts();
+ private Linguistics linguistics = new SimpleLinguistics();
+ private SpecialTokens specialTokens = new SpecialTokens();
+
+ public IndexFacts getIndexFacts() {
+ return indexFacts;
+ }
+
+ public ParserEnvironment setIndexFacts(IndexFacts indexFacts) {
+ this.indexFacts = indexFacts;
+ return this;
+ }
+
+ public Linguistics getLinguistics() {
+ return linguistics;
+ }
+
+ public ParserEnvironment setLinguistics(Linguistics linguistics) {
+ this.linguistics = linguistics;
+ return this;
+ }
+
+ public SpecialTokens getSpecialTokens() {
+ return specialTokens;
+ }
+
+ public ParserEnvironment setSpecialTokens(SpecialTokens specialTokens) {
+ this.specialTokens = specialTokens;
+ return this;
+ }
+
+ public static ParserEnvironment fromExecutionContext(Execution.Context context) {
+ ParserEnvironment env = new ParserEnvironment();
+ if (context == null) {
+ return env;
+ }
+ if (context.getIndexFacts() != null) {
+ env.setIndexFacts(context.getIndexFacts());
+ }
+ if (context.getLinguistics() != null) {
+ env.setLinguistics(context.getLinguistics());
+ }
+ SpecialTokenRegistry registry = context.getTokenRegistry();
+ if (registry != null) {
+ env.setSpecialTokens(registry.getSpecialTokens("default"));
+ }
+ return env;
+ }
+
+ public static ParserEnvironment fromParserEnvironment(ParserEnvironment environment) {
+ return new ParserEnvironment()
+ .setIndexFacts(environment.indexFacts)
+ .setLinguistics(environment.linguistics)
+ .setSpecialTokens(environment.specialTokens);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java b/container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java
new file mode 100644
index 00000000000..e0a3338fec2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java
@@ -0,0 +1,48 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.parser;
+
+import com.yahoo.prelude.query.parser.*;
+import com.yahoo.search.Query;
+import com.yahoo.search.yql.YqlParser;
+
+/**
+ * <p>Implements a factory for {@link Parser}.</p>
+ *
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ * @since 5.1.4
+ */
+public final class ParserFactory {
+
+ private ParserFactory() {
+ // hide
+ }
+
+ /**
+ * Creates a {@link Parser} appropriate for the given <tt>Query.Type</tt>, providing the Parser with access to
+ * the {@link ParserEnvironment} given.
+ *
+ * @param type the query type for which to create a Parser
+ * @param environment the environment settings to attach to the Parser
+ * @return the created Parser
+ */
+ public static Parser newInstance(Query.Type type, ParserEnvironment environment) {
+ switch (type) {
+ case ALL:
+ return new AllParser(environment);
+ case ANY:
+ return new AnyParser(environment);
+ case PHRASE:
+ return new PhraseParser(environment);
+ case ADVANCED:
+ return new AdvancedParser(environment);
+ case WEB:
+ return new WebParser(environment);
+ case PROGRAMMATIC:
+ return new ProgrammaticParser();
+ case YQL:
+ return new YqlParser(environment);
+ default:
+ throw new UnsupportedOperationException(type.toString());
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/package-info.java b/container-search/src/main/java/com/yahoo/search/query/parser/package-info.java
new file mode 100644
index 00000000000..ddae3e83ddb
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/parser/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Provides access to parsing query strings into queries
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.parser;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java
new file mode 100644
index 00000000000..393aba2b002
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java
@@ -0,0 +1,40 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileFieldType;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+final class AllReferencesQueryProfileVisitor extends PrefixQueryProfileVisitor {
+
+ /** A map of query profile types */
+ private Set<CompoundName> references = new HashSet<>();
+
+ public AllReferencesQueryProfileVisitor(CompoundName prefix) {
+ super(prefix);
+ }
+
+ @Override
+ public void onValue(String name, Object value, DimensionBinding binding, QueryProfile owner) {}
+
+ @Override
+ public void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) {
+ references.add(currentPrefix);
+ }
+
+ /** Returns the values resulting from this visiting */
+ public Set<CompoundName> getResult() { return references; }
+
+ /** Returns false - we are not done until we have seen all */
+ public boolean isDone() { return false; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java
new file mode 100644
index 00000000000..fb9638a958b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileFieldType;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+final class AllTypesQueryProfileVisitor extends PrefixQueryProfileVisitor {
+
+ /** A map of query profile types */
+ private Map<CompoundName, QueryProfileType> types = new HashMap<>();
+
+ public AllTypesQueryProfileVisitor(CompoundName prefix) {
+ super(prefix);
+ }
+
+ @Override
+ public void onValue(String name, Object value, DimensionBinding binding, QueryProfile owner) {}
+
+
+ @Override
+ public void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) {
+ if (profile.getType() != null)
+ addReachableTypes(currentPrefix, profile.getType());
+ }
+
+ private void addReachableTypes(CompoundName name, QueryProfileType type) {
+ types.put(name, type);
+ for (FieldDescription fieldDescription : type.fields().values()) {
+ if ( ! (fieldDescription.getType() instanceof QueryProfileFieldType)) continue;
+ QueryProfileFieldType fieldType = (QueryProfileFieldType)fieldDescription.getType();
+ if (fieldType.getQueryProfileType() !=null) {
+ addReachableTypes(name.append(fieldDescription.getName()), fieldType.getQueryProfileType());
+ }
+ }
+ }
+
+ /** Returns the values resulting from this visiting */
+ public Map<CompoundName, QueryProfileType> getResult() { return types; }
+
+ /** Returns false - we are not done until we have seen all */
+ public boolean isDone() { return false; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java
new file mode 100644
index 00000000000..65c3480272e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java
@@ -0,0 +1,45 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+final class AllUnoverridableQueryProfileVisitor extends PrefixQueryProfileVisitor {
+
+ /** A map of query profile types */
+ private Set<CompoundName> unoverridables = new HashSet<>();
+
+ public AllUnoverridableQueryProfileVisitor(CompoundName prefix) {
+ super(prefix);
+ }
+
+ @Override
+ public void onValue(String name, Object value, DimensionBinding binding, QueryProfile owner) {
+ addUnoverridable(name, currentPrefix.append(name), binding, owner);
+ }
+
+ @Override
+ public void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) {
+ addUnoverridable(currentPrefix.last(), currentPrefix, binding, owner);
+ }
+
+ private void addUnoverridable(String localName, CompoundName fullName, DimensionBinding binding, QueryProfile owner) {
+ if (owner == null) return;
+
+ Boolean isOverridable = owner.isLocalOverridable(localName, binding);
+ if (isOverridable != null && ! isOverridable)
+ unoverridables.add(fullName);
+ }
+
+ /** Returns the values resulting from this visiting */
+ public Set<CompoundName> getResult() { return unoverridables; }
+
+ /** Returns false - we are not done until we have seen all */
+ public boolean isDone() { return false; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java
new file mode 100644
index 00000000000..bef5b00c51b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java
@@ -0,0 +1,44 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+final class AllValuesQueryProfileVisitor extends PrefixQueryProfileVisitor {
+
+ private Map<String,Object> values=new HashMap<>();
+
+ /* Lists all values starting at prefix */
+ public AllValuesQueryProfileVisitor(CompoundName prefix) {
+ super(prefix);
+ }
+
+ public @Override void onValue(String localName, Object value, DimensionBinding binding, QueryProfile owner) {
+ putValue(localName, value, values);
+ }
+
+ public @Override void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) {
+ putValue("", profile.getValue(), values);
+ }
+
+ private final void putValue(String key, Object value, Map<String, Object> values) {
+ if (value == null) return;
+ CompoundName fullName = currentPrefix.append(key);
+ if (fullName.isEmpty()) return; // Avoid putting a non-leaf (subtree) root in the list
+ if (values.containsKey(fullName.toString())) return; // The first value encountered has priority
+ values.put(fullName.toString(), value);
+ }
+
+ /** Returns the values resulting from this visiting */
+ public Map<String, Object> getResult() { return values; }
+
+ /** Returns false - we are not done until we have seen all */
+ public boolean isDone() { return false; }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java
new file mode 100644
index 00000000000..71b27c6da63
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java
@@ -0,0 +1,139 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.protect.Validator;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * <p>A wrapper of a query profile where overrides to the values in the referenced
+ * profile can be set.</p>
+ *
+ * <p>This is used to allow configured overrides (in a particular referencing profile) of a referenced query profile.
+ *
+ * <p>Properties which are defined as not overridable in the type (if any) of the referenced query profile
+ * cannot be set.</p>
+ *
+ * @author bratseth
+ */
+public class BackedOverridableQueryProfile extends OverridableQueryProfile implements Cloneable {
+
+ /** The backing read only query profile, or null if this is not backed */
+ private QueryProfile backingProfile;
+
+ /**
+ * Creates an overridable profile from the given backing profile. The backing profile will never be
+ * written to.
+ *
+ * @param backingProfile the backing profile, which is assumed read only, never null
+ */
+ public BackedOverridableQueryProfile(QueryProfile backingProfile) {
+ Validator.ensureNotNull("An overridable query profile must be backed by a real query profile",backingProfile);
+ setType(backingProfile.getType());
+ this.backingProfile=backingProfile;
+ }
+
+ @Override
+ public synchronized void freeze() {
+ super.freeze();
+ backingProfile.freeze();
+ }
+
+ @Override
+ protected Object localLookup(String localName, DimensionBinding dimensionBinding) {
+ Object valueInThis=super.localLookup(localName,dimensionBinding);
+ if (valueInThis!=null) return valueInThis;
+ return backingProfile.localLookup(localName,dimensionBinding);
+ }
+
+ protected Boolean isLocalInstanceOverridable(String localName) {
+ Boolean valueInThis=super.isLocalInstanceOverridable(localName);
+ if (valueInThis!=null) return valueInThis;
+ return backingProfile.isLocalInstanceOverridable(localName);
+ }
+
+ @Override
+ protected QueryProfile createSubProfile(String name,DimensionBinding dimensionBinding) {
+ Object backing=backingProfile.lookup(new CompoundName(name),true,dimensionBinding.createFor(backingProfile.getDimensions()));
+ if (backing!=null && backing instanceof QueryProfile)
+ return new BackedOverridableQueryProfile((QueryProfile)backing);
+ else
+ return new OverridableQueryProfile(); // Nothing is set in this branch, so nothing to override, but need override checking
+ }
+
+ /** Returns a clone of this which can be independently overridden, but which refers to the same backing profile */
+ @Override
+ public BackedOverridableQueryProfile clone() {
+ BackedOverridableQueryProfile clone=(BackedOverridableQueryProfile)super.clone();
+ return clone;
+ }
+
+ /** Returns the query profile backing this */
+ public QueryProfile getBacking() { return backingProfile; }
+
+ @Override
+ public void addInherited(QueryProfile inherited) {
+ backingProfile.addInherited(inherited);
+ }
+
+ void addInheritedHere(QueryProfile inherited) {
+ super.addInherited(inherited);
+ }
+
+ @Override
+ protected void visitVariants(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) {
+ super.visitVariants(allowContent, visitor, dimensionBinding);
+ if (visitor.isDone()) return;
+ backingProfile.visitVariants(allowContent, visitor, dimensionBinding);
+ }
+
+ @Override
+ protected void visitInherited(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) {
+ super.visitInherited(allowContent,visitor,dimensionBinding, owner);
+ if (visitor.isDone()) return;
+ backingProfile.visitInherited(allowContent,visitor,dimensionBinding,owner);
+ }
+
+ /** Returns a value from the content of this: The value in this, or the value from the backing if not set in this */
+ protected Object getContent(String localKey) {
+ Object value=super.getContent(localKey);
+ if (value!=null) return value;
+ return backingProfile.getContent(localKey);
+ }
+
+ /**
+ * Returns all the content from this:
+ * All the values in this, and all values in the backing where an overriding value is not set in this
+ */
+ @Override
+ protected Map<String,Object> getContent() {
+ Map<String,Object> thisContent=super.getContent();
+ Map<String,Object> backingContent=backingProfile.getContent();
+ if (thisContent.isEmpty()) return backingContent; // Shortcut
+ if (backingContent.isEmpty()) return thisContent; // Shortcut
+ Map<String,Object> content=new HashMap<>(backingContent);
+ content.putAll(thisContent);
+ return content;
+ }
+
+ @Override
+ public String toString() {
+ return "overridable wrapper of " + backingProfile.toString();
+ }
+
+ @Override
+ public boolean isExplicit() {
+ return backingProfile.isExplicit();
+ }
+
+ @Override
+ public List<String> getDimensions() {
+ List<String> dimensions=super.getDimensions();
+ if (dimensions!=null) return dimensions;
+ return backingProfile.getDimensions();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java b/container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java
new file mode 100644
index 00000000000..3c02677b676
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java
@@ -0,0 +1,159 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.component.provider.FreezableClass;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A HashMap wrapper which can be cloned without copying the wrapped map.
+ * Copying of the map is deferred until there is a write access to the wrapped map.
+ * This may be frozen, at which point no further modifications are allowed.
+ * Note that <b>until</b> this is cloned, the internal map may be both read and written.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class CopyOnWriteContent extends FreezableClass implements Cloneable {
+
+ // TODO: Now that we used CompiledQueryProfiles at runtime we can remove this
+
+ // Possible states:
+ // WRITABLE: The map can be freely modified - it is only used by this
+ // -> !isFrozen() && (map!=null || unmodifiableMap==null)
+ // COPYONWRITE: The map is referred by at least one clone - further modification must cause a copy
+ // -> !isFrozen() && (map==null && unmodifiableMap!=null)
+ // FROZEN: No further changes are allowed to the state of this, ever
+ // -> isFrozen()
+
+ // Possible start states:
+ // WRITABLE: When created using the public constructor
+ // COPYONWRITE: When created by cloning
+
+ // Possible state transitions:
+ // WRITABLE->COPYONWRITE: When this is cloned
+ // COPYONWRITE->WRITABLE: When a clone is written to
+ // (COPYONWRITE,WRITABLE)->FROZEN: When a profile is frozen
+
+ /** The modifiable content of this. Null if this is empty or if this is not in the WRITABLE state */
+ private Map<String,Object> map=null;
+ /**
+ * If map is non-null this is either null (not instantiated yet) or an unmodifiable wrapper of map,
+ * if map is null this is either null (this is empty) or a reference to the map of the content this was cloned from
+ */
+ private Map<String,Object> unmodifiableMap =null;
+
+ /** Create a WRITABLE, empty instance */
+ public CopyOnWriteContent() {
+ }
+
+ /** Create a COPYONWRITE instance with some initial state */
+ private static CopyOnWriteContent createInCopyOnWriteState(Map<String,Object> unmodifiableMap) {
+ CopyOnWriteContent content=new CopyOnWriteContent();
+ content.unmodifiableMap = unmodifiableMap;
+ return content;
+ }
+
+ /** Create a WRITABLE instance with some initial state */
+ private static CopyOnWriteContent createInWritableState(Map<String,Object> map) {
+ CopyOnWriteContent content=new CopyOnWriteContent();
+ content.map = map;
+ return content;
+ }
+
+ @Override
+ public void freeze() {
+ // Freeze this
+ if (unmodifiableMap==null)
+ unmodifiableMap= map!=null ? Collections.unmodifiableMap(map) : Collections.<String, Object>emptyMap();
+ map=null; // just to keep the states simpler
+
+ // Freeze content
+ for (Map.Entry<String,Object> entry : unmodifiableMap.entrySet()) {
+ if (entry.getValue() instanceof QueryProfile)
+ ((QueryProfile)entry.getValue()).freeze();
+ }
+ super.freeze();
+ }
+
+ private boolean isEmpty() {
+ return (map==null || map.isEmpty()) && (unmodifiableMap ==null || unmodifiableMap.isEmpty());
+ }
+
+ private boolean isWritable() {
+ return !isFrozen() && (map!=null || unmodifiableMap==null);
+ }
+
+ @Override
+ public CopyOnWriteContent clone() {
+ if (isEmpty()) return new CopyOnWriteContent(); // No referencing is necessary in this case
+ if (isDeepUnmodifiable(unmodifiableMap())) {
+ // Create an instance pointing to this and put both in the COPYONWRITE state
+ unmodifiableMap(); // Make sure we have an unmodifiable reference to the map below
+ map=null; // Put this into the COPYONWRITE state (unless it is already frozen, in which case this is a noop)
+ return createInCopyOnWriteState(unmodifiableMap());
+ }
+ else {
+ // This contains query profiles, don't try to defer copying
+ return createInWritableState(deepClone(map));
+ }
+ }
+
+ private boolean isDeepUnmodifiable(Map<String,Object> map) {
+ for (Object value : map.values())
+ if (value instanceof QueryProfile && !((QueryProfile)value).isFrozen()) return false;
+ return true; // all other values are primitives
+ }
+
+ /** Deep clones a map - this handles all value types which can be found in a query profile */
+ static Map<String,Object> deepClone(Map<String,Object> map) {
+ if (map==null) return null;
+ Map<String,Object> mapClone=new HashMap<>(map.size());
+ for (Map.Entry<String,Object> entry : map.entrySet())
+ mapClone.put(entry.getKey(),QueryProfile.cloneIfNecessary(entry.getValue()));
+ return mapClone;
+ }
+
+
+ //------- Content access -------------------------------------------------------
+
+ public Map<String,Object> unmodifiableMap() {
+ if (isEmpty()) return Collections.emptyMap();
+ if (map==null) // in COPYONWRITE or FROZEN state
+ return unmodifiableMap;
+ // In WRITABLE state: Create unmodifiable wrapper if necessary and return it
+ if (unmodifiableMap==null)
+ unmodifiableMap=Collections.unmodifiableMap(map);
+ return unmodifiableMap;
+ }
+
+ public Object get(String key) {
+ if (map!=null) return map.get(key);
+ if (unmodifiableMap!=null) return unmodifiableMap.get(key);
+ return null;
+ }
+
+ public void put(String key,Object value) {
+ ensureNotFrozen();
+ copyIfNotWritable();
+ if (map==null)
+ map=new HashMap<>();
+ map.put(key,value);
+ }
+
+ public void remove(String key) {
+ ensureNotFrozen();
+ copyIfNotWritable();
+ if (map!=null)
+ map.remove(key);
+ }
+
+ private void copyIfNotWritable() {
+ if (isWritable()) return;
+ // move from COPYONWRITE to WRITABLE state
+ map=new HashMap<>(unmodifiableMap); // deep clone is not necessary as this map is shallowly modifiable
+ unmodifiableMap=null; // will be created as needed
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java
new file mode 100644
index 00000000000..9adacee74af
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java
@@ -0,0 +1,223 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An immutable, binding of a list of dimensions to dimension values
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DimensionBinding {
+
+ /** The dimensions of this */
+ private List<String> dimensions=null;
+
+ /** The values matching those dimensions */
+ private DimensionValues values;
+
+ /** The binding from those dimensions to values, and possibly other values */
+ private Map<String,String> context;
+
+ public static final DimensionBinding nullBinding =
+ new DimensionBinding(Collections.<String>unmodifiableList(Collections.<String>emptyList()), DimensionValues.empty, null);
+
+ public static final DimensionBinding invalidBinding =
+ new DimensionBinding(Collections.<String>unmodifiableList(Collections.<String>emptyList()), DimensionValues.empty, null);
+
+ /** Whether the value array contains only nulls */
+ private boolean containsAllNulls;
+
+ /** Creates a binding from a variant and a context. Any of the arguments may be null. */
+ public static DimensionBinding createFrom(List<String> dimensions, Map<String,String> context) {
+ if (dimensions==null || dimensions.size()==0) {
+ if (context==null) return nullBinding;
+ if (dimensions==null) return new DimensionBinding(null,DimensionValues.empty,context); // Null, but must preserve context
+ }
+
+ return new DimensionBinding(dimensions,extractDimensionValues(dimensions,context),context);
+ }
+
+ /** Creates a binding from a variant and a context. Any of the arguments may be null. */
+ public static DimensionBinding createFrom(List<String> dimensions, DimensionValues dimensionValues) {
+ if (dimensionValues==null || dimensionValues==DimensionValues.empty) return nullBinding;
+ if (dimensions==null) return new DimensionBinding(null,dimensionValues,null); // Null, but preserve raw material for creating a context later (in createFor)
+
+ return new DimensionBinding(dimensions,dimensionValues,null);
+ }
+
+ /** Returns a binding for a (possibly) new set of variants. Variants may be null, but not bindings */
+ public DimensionBinding createFor(List<String> newDimensions) {
+ if (newDimensions==null) return this; // Note: Not necessarily null - if no new variants then keep the existing binding
+ // if (this.context==null && values.length==0) return nullBinding; // No data from which to create a non-null binding
+ if (this.dimensions==newDimensions) return this; // Avoid creating a new object if the dimensions are the same
+
+ Map<String,String> context=this.context;
+ if (context==null)
+ context=this.values.asContext(this.dimensions !=null ? this.dimensions : newDimensions);
+ return new DimensionBinding(newDimensions,extractDimensionValues(newDimensions,context),context);
+ }
+
+ /**
+ * Creates a dimension binding. The dimensions list given should be unmodifiable.
+ * The array will not be modified. The context is needed in order to convert this binding to another
+ * given another set of variant dimensions.
+ */
+ private DimensionBinding(List<String> dimensions, DimensionValues values, Map<String,String> context) {
+ this.dimensions=dimensions;
+ this.values=values;
+ this.context = context;
+ containsAllNulls=values.isEmpty();
+ }
+
+ /** Returns a read-only list of the dimensions of this. This value is undefined if this isNull() */
+ public List<String> getDimensions() { return dimensions; }
+
+ /** Returns a context created from the dimensions and values of this */
+ public Map<String,String> getContext() {
+ if (context !=null) return context;
+ context =values.asContext(dimensions);
+ return context;
+ }
+
+ /**
+ * Returns the values for the dimensions of this. This value is undefined if this isEmpty()
+ * This array is always of the same length as the
+ * length of the dimension list - missing elements are represented as nulls.
+ * This is never null but may be empty.
+ */
+ public DimensionValues getValues() { return values; }
+
+ /** Returns true only if this binding is null (contains no values for its dimensions (if any) */
+ public boolean isNull() { return dimensions==null || containsAllNulls; }
+
+ /**
+ * Returns an array of the dimension values corresponding to the dimensions of this from the given context,
+ * in the corresponding order. The array is always of the same length as the number of dimensions.
+ * Dimensions which are not set in this context get a null value.
+ */
+ private static DimensionValues extractDimensionValues(List<String> dimensions,Map<String,String> context) {
+ String[] dimensionValues=new String[dimensions.size()];
+ if (context==null || context.size()==0) return DimensionValues.createFrom(dimensionValues);
+ for (int i=0; i<dimensions.size(); i++)
+ dimensionValues[i]=context.get(dimensions.get(i));
+ return DimensionValues.createFrom(dimensionValues);
+ }
+
+ /**
+ * Combines this binding with another if compatible.
+ * Two bindings are incompatible if
+ * <ul>
+ * <li>They contain a different value for the same key, or</li>
+ * <li>They contain the same pair of dimensions in a different order</li>
+ * </ul>
+ *
+ * @return the combined binding, or the special invalidBinding if these two bindings are incompatible
+ */
+ public DimensionBinding combineWith(DimensionBinding binding) {
+ List<String> combinedDimensions = combineDimensions(getDimensions(), binding.getDimensions());
+ if (combinedDimensions == null) return invalidBinding;
+
+ // not runtime, so assume we don't need to preserve values outside the dimensions
+ Map<String, String> combinedValues = combineValues(getContext(), binding.getContext());
+ if (combinedValues == null) return invalidBinding;
+
+ return DimensionBinding.createFrom(combinedDimensions, combinedValues);
+ }
+
+ /**
+ * Returns a combined list of dimensions from two separate lists,
+ * or null if they are incompatible.
+ * This is to combine two lists to one such that the partial order in both is preserved
+ * (or return null if impossible).
+ */
+ private List<String> combineDimensions(List<String> d1, List<String> d2) {
+ List<String> combined = new ArrayList<>();
+ int d1Index = 0, d2Index=0;
+ while (d1Index < d1.size() && d2Index < d2.size()) {
+ if (d1.get(d1Index).equals(d2.get(d2Index))) { // agreement on next element
+ combined.add(d1.get(d1Index));
+ d1Index++;
+ d2Index++;
+ }
+ else if ( ! d2.contains(d1.get(d1Index))) { // next in d1 is independent from d2
+ combined.add(d1.get(d1Index++));
+ }
+ else if ( ! d1.contains(d2.get(d2Index))) { // next in d2 is independent from d1
+ combined.add(d2.get(d2Index++));
+ }
+ else {
+ return null; // no independent and no agreement
+ }
+ }
+ if (d1Index < d1.size())
+ combined.addAll(d1.subList(d1Index, d1.size()));
+ else if (d2Index < d2.size())
+ combined.addAll(d2.subList(d2Index, d2.size()));
+
+ return combined;
+ }
+
+ /**
+ * Returns a combined map of dimension values from two separate maps,
+ * or null if they are incompatible.
+ */
+ private Map<String, String> combineValues(Map<String, String> m1, Map<String, String> m2) {
+ Map<String, String> combinedValues = new HashMap<>(m1);
+ for (Map.Entry<String, String> m2Entry : m2.entrySet()) {
+ if (m2Entry.getValue() == null) continue;
+ String m1Value = m1.get(m2Entry.getKey());
+ if (m1Value != null && ! m1Value.equals(m2Entry.getValue()))
+ return null; // conflicting values of a key
+ combinedValues.put(m2Entry.getKey(), m2Entry.getValue());
+ }
+ return combinedValues;
+ }
+
+ private boolean intersects(List<String> l1, List<String> l2) {
+ for (String l1Item : l1)
+ if (l2.contains(l1Item))
+ return true;
+ return false;
+ }
+
+ /**
+ * Returns true if <code>this == invalidBinding</code>
+ */
+ public boolean isInvalid() { return this == invalidBinding; }
+
+ @Override
+ public String toString() {
+ if (isInvalid()) return "Invalid DimensionBinding";
+ if (dimensions==null) return "DimensionBinding []";
+ StringBuilder b=new StringBuilder("DimensionBinding [");
+ for (int i=0; i<dimensions.size(); i++) {
+ b.append(dimensions.get(i)).append("=").append(values.get(i));
+ if (i<dimensions.size()-1)
+ b.append(", ");
+ }
+ b.append("]");
+ return b.toString();
+ }
+
+ /** Two bindings are equal if they contain the same dimensions and the same non-null values */
+ @Override
+ public boolean equals(Object o) {
+ if (o==this) return true;
+ if (! (o instanceof DimensionBinding)) return false;
+ DimensionBinding other = (DimensionBinding)o;
+ if ( ! this.dimensions.equals(other.dimensions)) return false;
+ if ( ! this.values.equals(other.values)) return false;
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return dimensions.hashCode() + 17 * values.hashCode();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java
new file mode 100644
index 00000000000..10435c4c6b5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java
@@ -0,0 +1,140 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An immutable set of dimension values.
+ * Note that this may contain more or fewer values than needed given a set of dimensions.
+ * Any missing values are treated as null.
+ */
+public class DimensionValues implements Comparable<DimensionValues> {
+
+ private final String[] values;
+
+ public static final DimensionValues empty=new DimensionValues(new String[] {});
+
+ public static DimensionValues createFrom(String[] values) {
+ if (values==null || values.length==0 || containsAllNulls(values)) return empty;
+ return new DimensionValues(values);
+ }
+
+ /**
+ * Creates a set of dimension values, where the input array <b>must</b> be of
+ * the right size, and where no copying is done.
+ *
+ * @param values the dimension values. This need not be normalized to the right size.
+ * The input array is copied by this.
+ */
+ private DimensionValues(String[] values) {
+ if (values==null) throw new NullPointerException("Dimension values cannot be null");
+ this.values=Arrays.copyOf(values,values.length);
+ }
+
+ /** Returns true if this is has the same value every place it has a value as the givenValues. */
+ public boolean matches(DimensionValues givenValues) {
+ for (int i=0; i<this.size() || i<givenValues.size() ; i++)
+ if ( ! matches(this.get(i),givenValues.get(i)))
+ return false;
+ return true;
+ }
+
+ private final boolean matches(String conditionString,String checkString) {
+ if (conditionString==null) return true;
+ return conditionString.equals(checkString);
+ }
+
+ /**
+ * Implements the sort order of this which is based on specificity
+ * where dimensions to the left are more significant:
+ * -1 is returned if this is more specific than other,
+ * 1 is returned if other is more specific than this,
+ * 0 is returned if none is more specific than the other.
+ * <p>
+ * <b>Note:</b> This ordering is not consistent with equals - it returns 0 when the same dimensions
+ * are <i>set</i>, regardless of what they are set <i>to</i>.
+ */
+ @Override
+ public int compareTo(DimensionValues other) {
+ for (int i=0; i<this.size() || i<other.size(); i++) {
+ if (get(i)!=null && other.get(i)==null)
+ return -1;
+ if (get(i)==null && other.get(i)!=null)
+ return 1;
+ }
+ return 0;
+ }
+
+ /** Helper method which uses compareTo to return whether this is most specific */
+ public boolean isMoreSpecificThan(DimensionValues other) {
+ return this.compareTo(other)<0;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this==o) return true;
+ if ( ! (o instanceof DimensionValues)) return false;
+ DimensionValues other=(DimensionValues)o;
+ for (int i=0; i<this.size() || i<other.size(); i++) {
+ if (get(i)==null) {
+ if (other.get(i)!=null) return false;
+ }
+ else {
+ if ( ! get(i).equals(other.get(i))) return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int hashCode = 0;
+ int i = 0;
+ for (String value : values) {
+ i++;
+ if (value != null)
+ hashCode += value.hashCode() * i;
+ }
+ return hashCode;
+ }
+
+ @Override
+ public String toString() { return Arrays.toString(values); }
+
+ public boolean isEmpty() {
+ return this==empty;
+ }
+
+ private static boolean containsAllNulls(String[] values) {
+ for (String value : values)
+ if (value!=null) return false;
+ return true;
+ }
+
+ public Map<String,String> asContext(List<String> dimensions) {
+ Map<String,String> context=new HashMap<>();
+ if (dimensions==null) return context;
+ for (int i=0; i<dimensions.size(); i++) {
+ context.put(dimensions.get(i),get(i));
+ }
+ return context;
+ }
+
+ /** Returns the string at the given index, <b>or null if it has no value at this index.</b> */
+ public String get(int index) {
+ if (index>=values.length) return null;
+ return values[index];
+ }
+
+ /** Returns the number of values in this (some of which may be null) */
+ public int size() { return values.length; }
+
+ /** Returns copy of the values in this in an array */
+ public String[] getValues() {
+ return Arrays.copyOf(values,values.length);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java b/container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java
new file mode 100644
index 00000000000..b9d631cdd10
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java
@@ -0,0 +1,89 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import java.io.File;
+import java.util.Map;
+
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.profile.config.QueryProfileXMLReader;
+
+/**
+ * A standalone tool for dumping query profile properties
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DumpTool {
+
+ /** Creates and returns a dump from some parameters */
+ public String resolveAndDump(String... args) {
+ if (args.length==0 || args[0].startsWith("-")) {
+ StringBuilder result=new StringBuilder();
+ result.append("Dumps all resolved query profile properties for a set of dimension values\n");
+ result.append("USAGE: dump [query-profile] [dir]? [parameters]?\n");
+ result.append(" and [query-profile] is the name of the query profile to dump the values of\n");
+ result.append(" and [dir] is a path to an application package or query profile directory. Default: current dir\n");
+ result.append(" and [parameters] is the http request encoded dimension keys used during resolving. Default: none\n");
+ result.append("Examples:\n");
+ result.append(" dump default\n");
+ result.append(" - dumps the 'default' profile non-variant values in the current dir\n");
+ result.append(" dump default x=x1&y=y1\n");
+ result.append(" - dumps the 'default' profile resolved with dimensions values x=x1 and y=y1 in the current dir\n");
+ result.append(" dump default myapppackage\n");
+ result.append(" - dumps the 'default' profile non-variant values in myapppackage/search/query-profiles\n");
+ result.append(" dump default dev/myprofiles x=x1&y=y1\n");
+ result.append(" - dumps the 'default' profile resolved with dimensions values x=x1 and y=y1 in dev/myprofiles\n");
+ return result.toString();
+ }
+
+ // Find what the arguments means
+ if (args.length>=3) {
+ return dump(args[0],args[1],args[2]);
+ }
+ else if (args.length==2) {
+ if (args[1].indexOf("=")>=0)
+ return dump(args[0],"",args[1]);
+ else
+ return dump(args[0],args[1],"");
+ }
+ else { // args.length=1
+ return dump(args[0],"","");
+ }
+ }
+
+ private String dump(String profileName,String dir,String parameters) {
+ // Import profiles
+ if (dir.isEmpty())
+ dir=".";
+ File dirInAppPackage=new File(dir,"search/query-profiles");
+ if (dirInAppPackage.exists())
+ dir=dirInAppPackage.getPath();
+ QueryProfileXMLReader reader = new QueryProfileXMLReader();
+ QueryProfileRegistry registry = reader.read(dir);
+ registry.freeze();
+
+ // Dump (through query to get wiring & parameter parsing done easily)
+ Query query = new Query("?" + parameters, registry.compile().findQueryProfile(profileName));
+ Map<String,Object> properties=query.properties().listProperties();
+
+ // Create result
+ StringBuilder b=new StringBuilder();
+ for (Map.Entry<String,Object> property : properties.entrySet()) {
+ b.append(property.getKey());
+ b.append("=");
+ b.append(property.getValue().toString());
+ b.append("\n");
+ }
+ return b.toString();
+ }
+
+ public static void main(String... args) {
+ try {
+ System.out.print(new DumpTool().resolveAndDump(args));
+ }
+ catch (Exception e) {
+ System.err.println(Exceptions.toMessageString(e));
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java
new file mode 100644
index 00000000000..73c0fcd2cb1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java
@@ -0,0 +1,70 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.search.query.profile.types.FieldDescription;
+
+import java.util.List;
+
+/**
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+final class FieldDescriptionQueryProfileVisitor extends QueryProfileVisitor {
+
+ /** The result, or null if none */
+ private FieldDescription result = null;
+
+ private final List<String> name;
+
+ private int nameIndex=-1;
+
+ private boolean enteringContent=false;
+
+ public FieldDescriptionQueryProfileVisitor(List<String> name) {
+ this.name=name;
+ }
+
+ @Override
+ public String getLocalKey() {
+ return name.get(nameIndex);
+ }
+
+ @Override
+ public boolean enter(String name) {
+ if (nameIndex+2<this.name.size()) {
+ nameIndex++;
+ enteringContent=true;
+ }
+ else {
+ enteringContent=false;
+ }
+ return enteringContent;
+ }
+
+ @Override
+ public void leave(String name) {
+ nameIndex--;
+ }
+
+ @Override
+ public void onValue(String name,Object value, DimensionBinding binding, QueryProfile owner) {
+ }
+
+ @Override
+ public void onQueryProfile(QueryProfile profile, DimensionBinding binding, QueryProfile owner) {
+ if (enteringContent) return; // not at leaf query profile
+ if (profile.getType() == null) return;
+ result = profile.getType().getField(name.get(name.size()-1));
+ }
+
+ @Override
+ public boolean isDone() {
+ return result != null;
+ }
+
+ public FieldDescription result() { return result; }
+
+ @Override
+ public String toString() {
+ return "a query profile type visitor (hash " + hashCode() + ") with current value " + result;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java b/container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java
new file mode 100644
index 00000000000..242c551f876
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java
@@ -0,0 +1,26 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.types.FieldType;
+import com.yahoo.search.query.properties.PropertyMap;
+
+/**
+ * A map which stores all types which cannot be stored in a query profile
+ * that is rich model objects.
+ * <p>
+ * This map will deep copy not only the model object map, but also each
+ * clonable member in the map.
+ *
+ * @author bratseth
+ */
+public class ModelObjectMap extends PropertyMap {
+
+ /** Returns true if the class of the value is not acceptable as a query profile value */
+ @Override
+ protected boolean shouldSet(CompoundName name,Object value) {
+ if (value==null) return true;
+ return FieldType.fromClass(value.getClass())==null;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java
new file mode 100644
index 00000000000..5d0bffa1ea8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+/**
+ * A regular query profile which knows it is storing overrides (not configured profiles)
+ * and that implements override legality checking.
+ *
+ * @author bratseth
+ */
+public class OverridableQueryProfile extends QueryProfile {
+
+ private static final String simpleClassName = OverridableQueryProfile.class.getSimpleName();
+
+ /** Creates an unbacked overridable query profile */
+ protected OverridableQueryProfile() {
+ super(ComponentId.createAnonymousComponentId(simpleClassName));
+ }
+
+ @Override
+ protected Object checkAndConvertAssignment(String localName, Object inputValue, QueryProfileRegistry registry) {
+ Object value=super.checkAndConvertAssignment(localName, inputValue, registry);
+ if (value!=null && value.getClass() == QueryProfile.class) { // We are assigning a query profile - make it overridable
+ return new BackedOverridableQueryProfile((QueryProfile)value);
+ }
+ return value;
+ }
+
+ @Override
+ protected QueryProfile createSubProfile(String name,DimensionBinding binding) {
+ return new OverridableQueryProfile(); // Nothing is set in this branch, so nothing to override, but need override checking
+ }
+
+ /** Returns a clone of this which can be independently overridden */
+ @Override
+ public OverridableQueryProfile clone() {
+ if (isFrozen()) return this;
+ OverridableQueryProfile clone=(OverridableQueryProfile)super.clone();
+ clone.initId(ComponentId.createAnonymousComponentId(simpleClassName));
+ return clone;
+ }
+
+ @Override
+ public String toString() {
+ return "an overridable query profile with no backing";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java
new file mode 100644
index 00000000000..2a22d58d8b7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java
@@ -0,0 +1,63 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+
+/**
+ * A query profile visitor which keeps track of name prefixes and can skip values outside a given prefix
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+abstract class PrefixQueryProfileVisitor extends QueryProfileVisitor {
+
+ /** Only call onValue/onQueryProfile for nodes having this prefix */
+ private final CompoundName prefix;
+
+ /** The current prefix, relative to prefix. */
+ protected CompoundName currentPrefix = CompoundName.empty;
+
+ private int prefixComponentIndex = -1;
+
+ public PrefixQueryProfileVisitor(CompoundName prefix) {
+ if (prefix == null)
+ prefix = CompoundName.empty;
+ this.prefix = prefix;
+ }
+
+ @Override
+ public final void onQueryProfile(QueryProfile profile, DimensionBinding binding, QueryProfile owner) {
+ if (prefixComponentIndex < prefix.size()) return; // Not in the prefix yet
+ onQueryProfileInsidePrefix(profile, binding, owner);
+ }
+
+ protected abstract void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner);
+
+ @Override
+ public final boolean enter(String name) {
+ prefixComponentIndex++;
+ if (prefixComponentIndex-1 < prefix.size()) return true; // we're in the given prefix, which should not be included in the name
+ currentPrefix = currentPrefix.append(name);
+ return true;
+ }
+
+ @Override
+ public final void leave(String name) {
+ prefixComponentIndex--;
+ if (prefixComponentIndex < prefix.size()) return; // we're in the given prefix, which should not be included in the name
+ if ( ! name.isEmpty() && ! currentPrefix.isEmpty())
+ currentPrefix = currentPrefix.first(currentPrefix.size() - 1);
+ }
+
+ /**
+ * Returns the correct prefix component if we are still going down the prefix path,
+ * or null to get all if we are inside the prefix
+ */
+ @Override
+ public String getLocalKey() {
+ if (prefixComponentIndex < prefix.size())
+ return prefix.get(prefixComponentIndex);
+ else
+ return null;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java
new file mode 100644
index 00000000000..55210717305
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java
@@ -0,0 +1,835 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.FreezableSimpleComponent;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.processing.request.Properties;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfile;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileFieldType;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A query profile is a data container with an id and a class (type). More precisely, it contains
+ * <ul>
+ * <li>An id, on the form name:version, where the version is optional, and follows the same rules as for other search container components.
+ * <li>A class id referring to the class defining this profile (see Query Profile Classes below)
+ * <li>A (possibly empty) list of ids of inherited query profiles
+ * <li>A (possibly empty) list of declarative predicates over search request parameters which defines when this query profile is applicable (see Query Profile Selection below)
+ * <li>The data content, which consists of
+ * <ul>
+ * <li>named values
+ * <li>named references to other profiles
+ * </ul>
+ * </ul>
+ *
+ * This serves the purpose of an intermediate format between configuration and runtime structures - the runtime
+ * structure used is QueryProfileProperties.
+ *
+ * @author bratseth
+ */
+public class QueryProfile extends FreezableSimpleComponent implements Cloneable {
+
+ /** Defines the permissible content of this, or null if any content is permissible */
+ private QueryProfileType type=null;
+
+ /** The value at this query profile - allows non-fields to have values, e.g a=value1, a.b=value2 */
+ private Object value=null;
+
+ /** The variants of this, or null if none */
+ private QueryProfileVariants variants=null;
+
+ /** The resolved variant dimensions of this, or null if none or not resolved yet (is resolved at freeze) */
+ private List<String> resolvedDimensions=null;
+
+ /** The query profiles inherited by this, or null if none */
+ private List<QueryProfile> inherited=null;
+
+ /** The content of this profile. The values may be primitives, substitutable strings or other query profiles */
+ private CopyOnWriteContent content=new CopyOnWriteContent();
+
+ /**
+ * Field override settings: fieldName→OverrideValue. These overrides the override
+ * setting in the type (if any) of this field). If there are no query profile level settings, this is null.
+ */
+ private Map<String,Boolean> overridable=null;
+
+ /**
+ * Creates a new query profile from an id.
+ * The query profile can be modified freely (but not accessed) until it is {@link #freeze frozen}.
+ * At that point it becomes readable but unmodifiable, which it stays until it goes out of reference.
+ */
+ public QueryProfile(ComponentId id) {
+ super(id);
+ if ( ! id.isAnonymous())
+ validateName(id.getName());
+ }
+
+ /** Convenience shorthand for new QueryProfile(new ComponentId(idString)) */
+ public QueryProfile(String idString) {
+ this(new ComponentId(idString));
+ }
+
+ // ----------------- Public API -------------------------------------------------------------------------------
+
+ // ----------------- Setters and getters
+
+ /** Returns the type of this or null if it has no type */
+ public QueryProfileType getType() { return type; }
+
+ /** Sets the type of this, or set to null to not use any type checking in this profile */
+ public void setType(QueryProfileType type) { this.type=type; }
+
+ /** Returns the virtual variants of this, or null if none */
+ public QueryProfileVariants getVariants() { return variants; }
+
+ /**
+ * Returns the list of profiles inherited by this.
+ * Note that order matters for inherited profiles - variables are resolved depth first in the order found in
+ * the inherited list. This always returns an unmodifiable list - use addInherited to add.
+ */
+ public List<QueryProfile> inherited() {
+ if (isFrozen()) return inherited; // Frozen profiles always have an unmodifiable, non-null list
+ if (inherited==null) return Collections.emptyList();
+ return Collections.unmodifiableList(inherited);
+ }
+
+ /** Adds a profile to the end of the inherited list of this. Throws an exception if this is frozen. */
+ public void addInherited(QueryProfile profile) {
+ addInherited(profile,(DimensionValues)null);
+ }
+
+ public final void addInherited(QueryProfile profile,String[] dimensionValues) {
+ addInherited(profile,DimensionValues.createFrom(dimensionValues));
+ }
+
+ /** Adds a profile to the end of the inherited list of this for the given variant. Throws an exception if this is frozen. */
+ public void addInherited(QueryProfile profile, DimensionValues dimensionValues) {
+ ensureNotFrozen();
+
+ DimensionBinding dimensionBinding=DimensionBinding.createFrom(getDimensions(),dimensionValues);
+ if (dimensionBinding.isNull()) {
+ if (inherited==null)
+ inherited=new ArrayList<>();
+ inherited.add(profile);
+ }
+ else {
+ if (variants==null)
+ variants=new QueryProfileVariants(dimensionBinding.getDimensions(), this);
+ variants.inherit(profile,dimensionBinding.getValues());
+ }
+ }
+
+ /**
+ * Returns the content fields declared in this (i.e not including those inherited) as a read-only map.
+ * @throws IllegalStateException if this is frozen
+ */
+ public Map<String,Object> declaredContent() {
+ ensureNotFrozen();
+ return content.unmodifiableMap();
+ }
+
+ /**
+ * Returns if the given field is declared explicitly as overridable or not in this or any <i>nested</i> profiles
+ * (i.e not including overridable settings <i>inherited</i> and from <i>types</i>).
+ *
+ * @param name the (possibly dotted) field name to return
+ * @param context the context in which the name is resolved, or null if none
+ * @return true/false if this is declared overridable/not overridable in this instance, null if it is not
+ * given any value is <i>this</i> profile instance
+ * @throws IllegalStateException if this is frozen
+ */
+ public Boolean isDeclaredOverridable(String name, Map<String,String> context) {
+ return isDeclaredOverridable(new CompoundName(name),DimensionBinding.createFrom(getDimensions(),context));
+ }
+
+ /** Sets the dimensions over which this may vary. Note: This will erase any currently defined variants */
+ public void setDimensions(String[] dimensions) {
+ ensureNotFrozen();
+ variants=new QueryProfileVariants(dimensions, this);
+ }
+
+ /** Returns the value set at this node, to allow non-leafs to have values. Returns null if none. */
+ public Object getValue() { return value; }
+
+ public void setValue(Object value) {
+ ensureNotFrozen();
+ this.value=value;
+ }
+
+ /** Returns the variant dimensions to be used in this - an unmodifiable list of dimension names */
+ public List<String> getDimensions() {
+ if (isFrozen()) return resolvedDimensions;
+ if (variants!=null) return variants.getDimensions();
+ if (inherited==null) return null;
+ for (QueryProfile inheritedProfile : inherited) {
+ List<String> inheritedDimensions=inheritedProfile.getDimensions();
+ if (inheritedDimensions!=null) return inheritedDimensions;
+ }
+ return null;
+ }
+
+ // ----------------- Query profile facade API
+
+ /**
+ * Sets the overridability of a field in this profile,
+ * this overrides the corresponding setting in the type (if any)
+ */
+ public final void setOverridable(String fieldName, boolean overridable, Map<String,String> context) {
+ setOverridable(new CompoundName(fieldName), overridable,DimensionBinding.createFrom(getDimensions(), context));
+ }
+
+ /**
+ * Return all objects that start with the given prefix path using no context. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(String prefix) { return listValues(new CompoundName(prefix)); }
+
+ /**
+ * Return all objects that start with the given prefix path using no context. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(CompoundName prefix) { return listValues(prefix, null); }
+
+ /**
+ * Return all objects that start with the given prefix path. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(String prefix, Map<String,String> context) {
+ return listValues(new CompoundName(prefix), context);
+ }
+
+ /**
+ * Return all objects that start with the given prefix path. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(CompoundName prefix, Map<String,String> context) {
+ return listValues(prefix, context, null);
+ }
+
+ /**
+ * Adds all objects that start with the given path prefix to the given value map. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public Map<String, Object> listValues(CompoundName prefix, Map<String, String> context, Properties substitution) {
+ DimensionBinding dimensionBinding=DimensionBinding.createFrom(getDimensions(),context);
+
+ AllValuesQueryProfileVisitor visitor=new AllValuesQueryProfileVisitor(prefix);
+ accept(visitor,dimensionBinding, null);
+ Map<String,Object> values=visitor.getResult();
+
+ if (substitution==null) return values;
+ for (Map.Entry<String,Object> entry : values.entrySet()) {
+ if (entry.getValue().getClass()==String.class) continue; // Shortcut
+ if (entry.getValue() instanceof SubstituteString)
+ entry.setValue(((SubstituteString)entry.getValue()).substitute(context,substitution));
+ }
+ return values;
+ }
+
+ /**
+ * Lists types reachable from this, indexed by the prefix having that type.
+ * If this is itself typed, this' type will be included with an empty prefix
+ */
+ Map<CompoundName, QueryProfileType> listTypes(CompoundName prefix, Map<String, String> context) {
+ DimensionBinding dimensionBinding = DimensionBinding.createFrom(getDimensions(), context);
+ AllTypesQueryProfileVisitor visitor = new AllTypesQueryProfileVisitor(prefix);
+ accept(visitor, dimensionBinding, null);
+ return visitor.getResult();
+ }
+
+ /**
+ * Lists references reachable from this.
+ */
+ Set<CompoundName> listReferences(CompoundName prefix, Map<String, String> context) {
+ DimensionBinding dimensionBinding=DimensionBinding.createFrom(getDimensions(),context);
+ AllReferencesQueryProfileVisitor visitor=new AllReferencesQueryProfileVisitor(prefix);
+ accept(visitor,dimensionBinding,null);
+ return visitor.getResult();
+ }
+
+ /**
+ * Lists every entry (value or reference) reachable from this which is not overridable
+ */
+ Set<CompoundName> listUnoverridable(CompoundName prefix, Map<String, String> context) {
+ DimensionBinding dimensionBinding = DimensionBinding.createFrom(getDimensions(),context);
+ AllUnoverridableQueryProfileVisitor visitor = new AllUnoverridableQueryProfileVisitor(prefix);
+ accept(visitor, dimensionBinding, null);
+ return visitor.getResult();
+ }
+
+ /**
+ * Returns a value from this query profile by resolving the given name:
+ * <ul>
+ * <li>The name up to the first dot is the value looked up in the value of this profile
+ * <li>The rest of the name (if any) is used as the name to look up in the referenced query profile
+ * </ul>
+ *
+ * If this name does not resolve <i>completely</i> into a value in this or any inherited profile, null is returned.
+ */
+ public final Object get(String name) { return get(name,(Map<String,String>)null); }
+
+ /** Returns a value from this using the given property context for resolution and using this for substitution */
+ public final Object get(String name, Map<String,String> context) {
+ return get(name,context,null);
+ }
+
+ /** Returns a value from this using the given dimensions for resolution */
+ public final Object get(String name, String[] dimensionValues) {
+ return get(name,dimensionValues,null);
+ }
+
+ public final Object get(String name, String[] dimensionValues, Properties substitution) {
+ return get(name,DimensionValues.createFrom(dimensionValues),substitution);
+ }
+
+ /** Returns a value from this using the given dimensions for resolution */
+ public final Object get(String name, DimensionValues dimensionValues, Properties substitution) {
+ return get(name,DimensionBinding.createFrom(getDimensions(),dimensionValues),substitution);
+ }
+
+ public final Object get(String name, Map<String,String> context, Properties substitution) {
+ return get(name,DimensionBinding.createFrom(getDimensions(),context),substitution);
+ }
+
+ public final Object get(CompoundName name, Map<String,String> context, Properties substitution) {
+ return get(name,DimensionBinding.createFrom(getDimensions(),context),substitution);
+ }
+
+ final Object get(String name, DimensionBinding binding,Properties substitution) {
+ return get(new CompoundName(name),binding,substitution);
+ }
+
+ final Object get(CompoundName name, DimensionBinding binding, Properties substitution) {
+ Object node=get(name,binding);
+ if (node!=null && node.getClass()==String.class) return node; // Shortcut
+ if (node instanceof SubstituteString) return ((SubstituteString)node).substitute(binding.getContext(),substitution);
+ return node;
+ }
+
+ final Object get(CompoundName name,DimensionBinding dimensionBinding) {
+ return lookup(name,false,dimensionBinding);
+ }
+
+ /**
+ * Returns the node at the position prescribed by the given name (without doing substitutions) -
+ * a primitive value, a substitutable string, a query profile, or null if not found.
+ */
+ public final Object lookup(String name, Map<String,String> context) {
+ return lookup(new CompoundName(name),true,DimensionBinding.createFrom(getDimensions(),context));
+ }
+
+ /** Sets a value in this or any nested profile using null as context */
+ public final void set(String name, Object value, QueryProfileRegistry registry) {
+ set(name,value,(Map<String,String>)null, registry);
+ }
+
+ /**
+ * Sets a value in this or any nested profile. Any missing structure needed to set this will be created.
+ * If this value is already set, this will overwrite the previous value.
+ *
+ * @param name the name of the field, possibly a dotted name which will cause setting of a variable in a subprofile
+ * @param value the value to assign to the name, a primitive wrapper, string or a query profile
+ * @param context the context used to resolve where this value should be set, or null if none
+ * @throws IllegalArgumentException if the given name is illegal given the types of this or any nested query profile
+ * @throws IllegalStateException if this query profile is frozen
+ */
+ public final void set(CompoundName name,Object value,Map<String,String> context, QueryProfileRegistry registry) {
+ set(name, value, DimensionBinding.createFrom(getDimensions(), context), registry);
+ }
+
+ public final void set(String name,Object value,Map<String,String> context, QueryProfileRegistry registry) {
+ set(new CompoundName(name), value, DimensionBinding.createFrom(getDimensions(), context), registry);
+ }
+
+ public final void set(String name,Object value,String[] dimensionValues, QueryProfileRegistry registry) {
+ set(name,value,DimensionValues.createFrom(dimensionValues), registry);
+ }
+
+ /**
+ * Sets a value in this or any nested profile. Any missing structure needed to set this will be created.
+ * If this value is already set, this will overwrite the previous value.
+ *
+ * @param name the name of the field, possibly a dotted name which will cause setting of a variable in a subprofile
+ * @param value the value to assign to the name, a primitive wrapper, string or a query profile
+ * @param dimensionValues the dimension values - will be matched by order to the dimensions set in this - if this is
+ * shorter or longer than the number of dimensions it will be adjusted as needed
+ * @param registry the registry used to resolve query profile references. If null is passed query profile references
+ * will cause an exception
+ * @throws IllegalArgumentException if the given name is illegal given the types of this or any nested query profile
+ * @throws IllegalStateException if this query profile is frozen
+ */
+ public final void set(String name,Object value,DimensionValues dimensionValues, QueryProfileRegistry registry) {
+ set(new CompoundName(name), value, DimensionBinding.createFrom(getDimensions(), dimensionValues), registry);
+ }
+
+ // ----------------- Misc
+
+ public boolean isExplicit() {
+ return !getId().isAnonymous();
+ }
+
+ /**
+ * Switches this from write-only to read-only mode.
+ * This profile can never be modified again after this method returns.
+ * Calling this on an already frozen profile has no effect.
+ * <p>
+ * Calling this will also freeze any profiles inherited and referenced by this.
+ */
+ // TODO: Remove/simplify as query profiles are not used at query time
+ public synchronized void freeze() {
+ if (isFrozen()) return;
+
+ resolvedDimensions=getDimensions();
+
+ if (variants !=null)
+ variants.freeze();
+
+ if (inherited!=null) {
+ for (QueryProfile inheritedProfile : inherited)
+ inheritedProfile.freeze();
+ }
+
+ content.freeze();
+
+ inherited= inherited==null ? ImmutableList.of() : ImmutableList.copyOf(inherited);
+
+ super.freeze();
+ }
+
+ @Override
+ public String toString() {
+ return "query profile '" + getId() + "'" + (type!=null ? " of type '" + type.getId() + "'" : "");
+ }
+
+ /**
+ * Returns a clone of this. The clone will not be frozen and will contain copied inherited and content collections
+ * pointing to the same values as this.
+ */
+ @Override
+ public QueryProfile clone() {
+ if (isFrozen()) return this;
+ QueryProfile clone=(QueryProfile)super.clone();
+ if (variants !=null)
+ clone.variants = variants.clone();
+ if (inherited!=null)
+ clone.inherited=new ArrayList<>(inherited);
+
+ if (this.content!=null)
+ clone.content=content.clone();
+
+ return clone;
+ }
+
+ /**
+ * Clones a value of a type which may appear in a query profile if cloning is necessary (i.e if it is
+ * not immutable). Returns the input type otherwise.
+ */
+ static Object cloneIfNecessary(Object object) {
+ if (object instanceof QueryProfile) return ((QueryProfile)object).clone();
+ return object; // Other types are immutable
+ }
+
+ /** Throws IllegalArgumentException if the given string is not a valid query profile name */
+ public static void validateName(String name) {
+ Matcher nameMatcher=namePattern.matcher(name);
+ if ( ! nameMatcher.matches())
+ throw new IllegalArgumentException("Illegal name '" + name + "'");
+ }
+
+ // ----------------- For subclass use --------------------------------------------------------------------
+
+ /** Override this to intercept all writes to this profile (or any nested profiles) */
+ protected void set(CompoundName name, Object value, DimensionBinding binding, QueryProfileRegistry registry) {
+ try {
+ setNode(name, value, null, binding, registry);
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Could not set '" + name + "' to '" + value + "'",e);
+ }
+ }
+
+ /** Returns this value, or its corresponding substitution string if it contains substitutions */
+ protected Object convertToSubstitutionString(Object value) {
+ if (value==null) return value;
+ if (value.getClass()!=String.class) return value;
+ SubstituteString substituteString=SubstituteString.create((String)value);
+ if (substituteString==null) return value;
+ return substituteString;
+ }
+
+ /** Returns the field description of this field, or null if it is not typed */
+ protected FieldDescription getFieldDescription(CompoundName name, DimensionBinding binding) {
+ FieldDescriptionQueryProfileVisitor visitor=new FieldDescriptionQueryProfileVisitor(name.asList());
+ accept(visitor, binding,null);
+ return visitor.result();
+ }
+
+ /**
+ * Returns true if this value is definitely overridable in this (set and not unoverridable),
+ * false if it is declared unoverridable (in instance or type), and null if this profile has no
+ * opinion on the matter because the value is not set in this.
+ */
+ Boolean isLocalOverridable(String localName,DimensionBinding binding) {
+ if (localLookup(localName, binding)==null) return null; // Not set
+ Boolean isLocalInstanceOverridable=isLocalInstanceOverridable(localName);
+ if (isLocalInstanceOverridable!=null)
+ return isLocalInstanceOverridable.booleanValue();
+ if (type!=null) return type.isOverridable(localName);
+ return true;
+ }
+
+ protected Boolean isLocalInstanceOverridable(String localName) {
+ if (overridable==null) return null;
+ return overridable.get(localName);
+ }
+
+ protected Object lookup(CompoundName name,boolean allowQueryProfileResult, DimensionBinding dimensionBinding) {
+ SingleValueQueryProfileVisitor visitor=new SingleValueQueryProfileVisitor(name.asList(),allowQueryProfileResult);
+ accept(visitor,dimensionBinding,null);
+ return visitor.getResult();
+ }
+
+ protected final void accept(QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) {
+ acceptAndEnter("", visitor, dimensionBinding, owner);
+ }
+
+ void acceptAndEnter(String key, QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) {
+ boolean allowContent=visitor.enter(key);
+ accept(allowContent, visitor, dimensionBinding, owner);
+ if (allowContent)
+ visitor.leave(key);
+ }
+
+ /**
+ * Visit the profiles and values referenced from this in order of decreasing precedence
+ *
+ * @param allowContent whether content in this should be visited
+ * @param visitor the visitor
+ * @param dimensionBinding the dimension binding to use
+ */
+ final void accept(boolean allowContent,QueryProfileVisitor visitor, DimensionBinding dimensionBinding, QueryProfile owner) {
+ visitor.onQueryProfile(this, dimensionBinding, owner);
+ if (visitor.isDone()) return;
+
+ visitVariants(allowContent,visitor,dimensionBinding);
+ if (visitor.isDone()) return;
+
+ if (allowContent) {
+ visitContent(visitor,dimensionBinding);
+ if (visitor.isDone()) return;
+ }
+
+ if (visitor.visitInherited())
+ visitInherited(allowContent, visitor, dimensionBinding, owner);
+ }
+
+ protected void visitVariants(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) {
+ if (getVariants()!=null)
+ getVariants().accept(allowContent, getType(), visitor, dimensionBinding);
+ }
+
+ protected void visitInherited(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) {
+ if (inherited==null) return;
+ for (QueryProfile inheritedProfile : inherited) {
+ inheritedProfile.accept(allowContent,visitor,dimensionBinding.createFor(inheritedProfile.getDimensions()), owner);
+ if (visitor.isDone()) return;
+ }
+ }
+
+ private void visitContent(QueryProfileVisitor visitor,DimensionBinding dimensionBinding) {
+ String contentKey=visitor.getLocalKey();
+
+ // Visit this' content
+ if (contentKey!=null) { // Get only the content of the current key
+ if (type!=null)
+ contentKey=type.unalias(contentKey);
+ visitor.acceptValue(contentKey, getContent(contentKey), dimensionBinding, this);
+ }
+ else { // get all content in this
+ for (Map.Entry<String,Object> entry : getContent().entrySet()) {
+ visitor.acceptValue(entry.getKey(), entry.getValue(), dimensionBinding, this);
+ if (visitor.isDone()) return;
+ }
+ }
+ }
+
+ /** Returns a value from the content of this, or null if not present */
+ protected Object getContent(String key) {
+ return content.get(key);
+ }
+
+ /** Returns all the content from this as an unmodifiable map */
+ protected Map<String,Object> getContent() {
+ return content.unmodifiableMap();
+ }
+
+ /** Sets the value of a node in <i>this</i> profile - the local name given must not be nested (contain dots) */
+ protected QueryProfile setLocalNode(String localName, Object value,QueryProfileType parentType,
+ DimensionBinding dimensionBinding, QueryProfileRegistry registry) {
+ if (parentType!=null && type==null && !isFrozen())
+ type=parentType;
+
+ value=checkAndConvertAssignment(localName, value, registry);
+ localPut(localName,value,dimensionBinding);
+ return this;
+ }
+
+ /**
+ * Combines an existing and a new value for a query property key.
+ * Return the new object to add to the state of the owning profile (/variant), or null if no new value needs to
+ * be added (usually because the new value was added to the existing).
+ */
+ static Object combineValues(Object newValue, Object existingValue) {
+ if (newValue instanceof QueryProfile) {
+ QueryProfile newProfile=(QueryProfile)newValue;
+ if ( existingValue==null || ! (existingValue instanceof QueryProfile)) {
+ if (!isModifiable(newProfile))
+ newProfile=new BackedOverridableQueryProfile(newProfile); // Make the query profile reference overridable
+ newProfile.value=existingValue;
+ return newProfile;
+ }
+
+ // if both are profiles:
+ return combineProfiles(newProfile,(QueryProfile)existingValue);
+ }
+ else {
+ if (existingValue instanceof QueryProfile) { // we need to set a non-leaf value on a query profile
+ QueryProfile existingProfile=(QueryProfile)existingValue;
+ if (isModifiable(existingProfile)) {
+ existingProfile.setValue(newValue);
+ return null;
+ }
+ else {
+ QueryProfile existingOverridable = new BackedOverridableQueryProfile((QueryProfile)existingValue);
+ existingOverridable.setValue(newValue);
+ return existingOverridable;
+ }
+ }
+ else {
+ return newValue;
+ }
+ }
+ }
+
+ private static QueryProfile combineProfiles(QueryProfile newProfile,QueryProfile existingProfile) {
+ QueryProfile returnValue=null;
+ QueryProfile existingModifiable;
+
+ // Ensure the existing profile is modifiable
+ if (existingProfile.getClass()==QueryProfile.class) {
+ existingModifiable = new BackedOverridableQueryProfile(existingProfile);
+ returnValue=existingModifiable;
+ }
+ else { // is an overridable wrapper
+ existingModifiable=existingProfile; // May be used as-is
+ }
+
+ // Make the existing profile inherit the new one
+ if (existingModifiable instanceof BackedOverridableQueryProfile)
+ ((BackedOverridableQueryProfile)existingModifiable).addInheritedHere(newProfile);
+ else
+ existingModifiable.addInherited(newProfile);
+
+ // Remove content from the existing which the new one does not allow overrides of
+ if (existingModifiable.content!=null) {
+ for (String key : existingModifiable.content.unmodifiableMap().keySet()) {
+ if ( ! newProfile.isLocalOverridable(key, null)) {
+ existingModifiable.content.remove(key);
+ }
+ }
+ }
+
+ return returnValue;
+ }
+
+ /** Returns whether the given profile may be modified from this profile */
+ private static boolean isModifiable(QueryProfile profile) {
+ if (profile.isFrozen()) return false;
+ if ( ! profile.isExplicit()) return true; // Implicitly defined from this - ok to modify then
+ if (! (profile instanceof BackedOverridableQueryProfile)) return false;
+ return true;
+ }
+
+ /**
+ * Converts to the type of the receiving field, if possible and necessary.
+ *
+ * @return the value to be assigned: the original or a converted value
+ * @throws IllegalArgumentException if the assignment is illegal
+ */
+ protected Object checkAndConvertAssignment(String localName, Object value, QueryProfileRegistry registry) {
+ if (type==null) return value; // no type checking
+
+ FieldDescription fieldDescription=type.getField(localName);
+ if (fieldDescription==null) {
+ if (type.isStrict())
+ throw new IllegalArgumentException("'" + localName + "' is not declared in " + type + ", and the type is strict");
+ return value;
+ }
+
+ if (registry == null && (fieldDescription.getType() instanceof QueryProfileFieldType))
+ throw new IllegalArgumentException("A registry was not passed: Query profile references is not supported");
+ Object convertedValue = fieldDescription.getType().convertFrom(value, registry);
+ if (convertedValue == null)
+ throw new IllegalArgumentException("'" + value + "' is not a " + fieldDescription.getType().toInstanceDescription());
+ return convertedValue;
+ }
+
+ /**
+ * Looks up all inherited profiles and adds any that matches this name.
+ * This default implementation returns an empty profile.
+ */
+ protected QueryProfile createSubProfile(String name,DimensionBinding dimensionBinding) {
+ QueryProfile queryProfile = new QueryProfile(ComponentId.createAnonymousComponentId(name));
+ return queryProfile;
+ }
+
+ /** Do a variant-aware content lookup in this */
+ protected Object localLookup(String name, DimensionBinding dimensionBinding) {
+ Object node=null;
+ if ( variants!=null && !dimensionBinding.isNull())
+ node=variants.get(name,type,true,dimensionBinding);
+ if (node==null)
+ node=content==null ? null : content.get(name);
+ return node;
+ }
+
+ // ----------------- Private ----------------------------------------------------------------------------------
+
+ private Boolean isDeclaredOverridable(CompoundName name,DimensionBinding dimensionBinding) {
+ QueryProfile parent= lookupParentExact(name, true, dimensionBinding);
+ if (parent.overridable==null) return null;
+ return parent.overridable.get(name.last());
+ }
+
+ /**
+ * Sets the overridability of a field in this profile,
+ * this overrides the corresponding setting in the type (if any)
+ */
+ private void setOverridable(CompoundName fieldName,boolean overridable,DimensionBinding dimensionBinding) {
+ QueryProfile parent= lookupParentExact(fieldName, true, dimensionBinding);
+ if (parent.overridable==null)
+ parent.overridable=new HashMap<>();
+ parent.overridable.put(fieldName.last(),overridable);
+ }
+
+ /** Sets a value to a (possibly non-local) node. The parent query profile holding the value is returned */
+ private void setNode(CompoundName name, Object value, QueryProfileType parentType,
+ DimensionBinding dimensionBinding, QueryProfileRegistry registry) {
+ ensureNotFrozen();
+ if (name.isCompound()) {
+ QueryProfile parent= getQueryProfileExact(name.first(), true, dimensionBinding);
+ parent.setNode(name.rest(), value,parentType, dimensionBinding.createFor(parent.getDimensions()), registry);
+ }
+ else {
+ setLocalNode(name.toString(), value,parentType, dimensionBinding, registry);
+ }
+ }
+
+ /**
+ * Looks up and, if necessary, creates, the query profile which should hold the given local name portion of the
+ * given name. If the name contains no dots, this is returned.
+ *
+ * @param name the name of the variable to lookup the parent of
+ * @param create whether or not to create the parent if it is not present
+ * @return the parent, or null if not present and created is false
+ */
+ private QueryProfile lookupParentExact(CompoundName name, boolean create, DimensionBinding dimensionBinding) {
+ CompoundName rest=name.rest();
+ if (rest.isEmpty()) return this;
+
+ QueryProfile topmostParent= getQueryProfileExact(name.first(), create, dimensionBinding);
+ if (topmostParent==null) return null;
+ return topmostParent.lookupParentExact(rest, create, dimensionBinding.createFor(topmostParent.getDimensions()));
+ }
+
+ /**
+ * Returns a query profile from this by name
+ *
+ * @param localName the local name of the profile in this, this is never a compound
+ * @param create whether the profile should be created if missing
+ * @return the created profile, or null if not present, and create is false
+ */
+ private QueryProfile getQueryProfileExact(String localName, boolean create, DimensionBinding dimensionBinding) {
+ Object node=localExactLookup(localName, dimensionBinding);
+ if (node!=null && node instanceof QueryProfile) {
+ return (QueryProfile)node;
+ }
+ if (!create) return null;
+
+ QueryProfile queryProfile=createSubProfile(localName,dimensionBinding);
+ if (type!=null) {
+ Class<?> legalClass=type.getValueClass(localName);
+ if (legalClass==null || ! legalClass.isInstance(queryProfile))
+ throw new RuntimeException("'" + localName + "' is not a legal query profile reference name in " + this);
+ queryProfile.setType(type.getType(localName));
+ }
+ localPut(localName,queryProfile,dimensionBinding);
+ return queryProfile;
+ }
+
+ /** Do a variant-aware content lookup in this - without looking in any wrapped content. But by matching variant bindings exactly only */
+ private Object localExactLookup(String name,DimensionBinding dimensionBinding) {
+ if (dimensionBinding.isNull()) return content==null ? null : content.get(name);
+ if (variants==null) return null;
+ QueryProfileVariant variant=variants.getVariant(dimensionBinding.getValues(),false);
+ if (variant==null) return null;
+ return variant.values().get(name);
+ }
+
+ /** Sets a value directly in this query profile (unless frozen) */
+ private void localPut(String localName,Object value,DimensionBinding dimensionBinding) {
+ ensureNotFrozen();
+
+ if (type!=null)
+ localName=type.unalias(localName);
+
+ validateName(localName);
+ value=convertToSubstitutionString(value);
+
+ if (dimensionBinding.isNull()) {
+ Object combinedValue;
+ if (value instanceof QueryProfile)
+ combinedValue = combineValues(value,content==null ? null : content.get(localName));
+ else
+ combinedValue = combineValues(value, localLookup(localName, dimensionBinding));
+
+ if (combinedValue!=null)
+ content.put(localName,combinedValue);
+ }
+ else {
+ if (variants==null)
+ variants=new QueryProfileVariants(dimensionBinding.getDimensions(), this);
+ variants.set(localName,dimensionBinding.getValues(),value);
+ }
+ }
+
+ private static final Pattern namePattern=Pattern.compile("[$a-zA-Z_/][-$a-zA-Z0-9_/()]*");
+
+ /**
+ * Returns a compiled version of this which produces faster lookup times
+ *
+ * @param registry the registry this will be added to by the caller, or null if none
+ */
+ public CompiledQueryProfile compile(CompiledQueryProfileRegistry registry) {
+ return QueryProfileCompiler.compile(this, registry);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java
new file mode 100644
index 00000000000..795c7655dfb
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java
@@ -0,0 +1,140 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfile;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.DimensionalMap;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Compile a set of query profiles into compiled profiles.
+ *
+ * @author bratseth
+ */
+public class QueryProfileCompiler {
+
+ private static final Logger log = Logger.getLogger(QueryProfileCompiler.class.getName());
+
+ public static CompiledQueryProfileRegistry compile(QueryProfileRegistry input) {
+ CompiledQueryProfileRegistry output = new CompiledQueryProfileRegistry(input.getTypeRegistry());
+ for (QueryProfile inputProfile : input.allComponents()) {
+ output.register(compile(inputProfile, output));
+ }
+ return output;
+ }
+
+ public static CompiledQueryProfile compile(QueryProfile in, CompiledQueryProfileRegistry registry) {
+ DimensionalMap.Builder<CompoundName, Object> values = new DimensionalMap.Builder<>();
+ DimensionalMap.Builder<CompoundName, QueryProfileType> types = new DimensionalMap.Builder<>();
+ DimensionalMap.Builder<CompoundName, Object> references = new DimensionalMap.Builder<>();
+ DimensionalMap.Builder<CompoundName, Object> unoverridables = new DimensionalMap.Builder<>();
+
+ // Resolve values for each existing variant and combine into a single data structure
+ Set<DimensionBindingForPath> variants = new HashSet<>();
+ collectVariants(CompoundName.empty, in, DimensionBinding.nullBinding, variants);
+ variants.add(new DimensionBindingForPath(DimensionBinding.nullBinding, CompoundName.empty)); // if this contains no variants
+ if (log.isLoggable(Level.FINE))
+ log.fine("Compiling " + in.toString() + " having " + variants.size() + " variants");
+ int i = 0;
+ for (DimensionBindingForPath variant : variants) {
+ if (log.isLoggable(Level.FINER))
+ log.finer(" Compiling variant " + i++ + ": " + variant);
+ for (Map.Entry<String, Object> entry : in.listValues(variant.path(), variant.binding().getContext(), null).entrySet())
+ values.put(variant.path().append(entry.getKey()), variant.binding(), entry.getValue());
+ for (Map.Entry<CompoundName, QueryProfileType> entry : in.listTypes(variant.path(), variant.binding().getContext()).entrySet())
+ types.put(variant.path().append(entry.getKey()), variant.binding(), entry.getValue());
+ for (CompoundName reference : in.listReferences(variant.path(), variant.binding().getContext()))
+ references.put(variant.path().append(reference), variant.binding(), Boolean.TRUE); // Used as a set; value is ignored
+ for (CompoundName name : in.listUnoverridable(variant.path(), variant.binding().getContext()))
+ unoverridables.put(variant.path().append(name), variant.binding(), Boolean.TRUE); // Used as a set; value is ignored
+ }
+
+ return new CompiledQueryProfile(in.getId(), in.getType(),
+ values.build(), types.build(), references.build(), unoverridables.build(),
+ registry);
+ }
+
+ /**
+ * Returns all the unique combinations of dimension values which have values set reachable from this profile.
+ *
+ * @param profile the profile we are collecting the variants of
+ * @param currentVariant the variant we must have to arrive at this point in the query profile graph
+ * @param allVariants the set of all variants accumulated so far
+ */
+ private static void collectVariants(CompoundName path, QueryProfile profile, DimensionBinding currentVariant, Set<DimensionBindingForPath> allVariants) {
+ for (QueryProfile inheritedProfile : profile.inherited())
+ collectVariants(path, inheritedProfile, currentVariant, allVariants);
+
+ collectVariantsFromValues(path, profile.getContent(), currentVariant, allVariants);
+
+ collectVariantsInThis(path, profile, currentVariant, allVariants);
+ if (profile instanceof BackedOverridableQueryProfile)
+ collectVariantsInThis(path, ((BackedOverridableQueryProfile) profile).getBacking(), currentVariant, allVariants);
+ }
+
+ private static void collectVariantsInThis(CompoundName path, QueryProfile profile, DimensionBinding currentVariant, Set<DimensionBindingForPath> allVariants) {
+ QueryProfileVariants profileVariants = profile.getVariants();
+ if (profileVariants != null) {
+ for (QueryProfileVariant variant : profile.getVariants().getVariants()) {
+ DimensionBinding combinedVariant =
+ DimensionBinding.createFrom(profile.getDimensions(), variant.getDimensionValues()).combineWith(currentVariant);
+ if (combinedVariant.isInvalid()) continue; // values at this point in the graph are unreachable
+ collectVariantsFromValues(path, variant.values(), combinedVariant, allVariants);
+ for (QueryProfile variantInheritedProfile : variant.inherited())
+ collectVariants(path, variantInheritedProfile, combinedVariant, allVariants);
+ }
+ }
+ }
+
+ private static void collectVariantsFromValues(CompoundName path, Map<String, Object> values, DimensionBinding currentVariant, Set<DimensionBindingForPath> allVariants) {
+ if ( ! values.isEmpty())
+ allVariants.add(new DimensionBindingForPath(currentVariant, path)); // there are actual values for this variant
+
+ for (Map.Entry<String, Object> entry : values.entrySet()) {
+ if (entry.getValue() instanceof QueryProfile)
+ collectVariants(path.append(entry.getKey()), (QueryProfile)entry.getValue(), currentVariant, allVariants);
+ }
+ }
+
+ private static class DimensionBindingForPath {
+
+ private final DimensionBinding binding;
+ private final CompoundName path;
+
+ public DimensionBindingForPath(DimensionBinding binding, CompoundName path) {
+ this.binding = binding;
+ this.path = path;
+ }
+
+ public DimensionBinding binding() { return binding; }
+ public CompoundName path() { return path; }
+
+ @Override
+ public boolean equals(Object o) {
+ if ( o == this ) return true;
+ if ( ! (o instanceof DimensionBindingForPath)) return false;
+ DimensionBindingForPath other = (DimensionBindingForPath)o;
+ return other.binding.equals(this.binding) && other.path.equals(this.path);
+ }
+
+ @Override
+ public int hashCode() {
+ return binding.hashCode() + 17*path.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return binding + " for path " + path;
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java
new file mode 100644
index 00000000000..2432cb2ab33
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java
@@ -0,0 +1,258 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.collections.Pair;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.processing.request.properties.PropertyMap;
+import com.yahoo.protect.Validator;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfile;
+import com.yahoo.search.query.profile.compiled.DimensionalValue;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Properties backed by a query profile.
+ * This has the scope of one query and is not multithread safe.
+ *
+ * @author bratseth
+ */
+public class QueryProfileProperties extends Properties {
+
+ private final CompiledQueryProfile profile;
+
+ // Note: The priority order is: values has precedence over references
+
+ /** Values which has been overridden at runtime, or null if none */
+ private Map<CompoundName, Object> values = null;
+ /** Query profile references which has been overridden at runtime, or null if none. Earlier values has precedence */
+ private List<Pair<CompoundName, CompiledQueryProfile>> references = null;
+
+ /** Creates an instance from a profile, throws an exception if the given profile is null */
+ public QueryProfileProperties(CompiledQueryProfile profile) {
+ Validator.ensureNotNull("The profile wrapped by this cannot be null", profile);
+ this.profile = profile;
+ }
+
+ /** Returns the query profile backing this, or null if none */
+ public CompiledQueryProfile getQueryProfile() { return profile; }
+
+ /** Gets a value from the query profile, or from the nested profile if the value is null */
+ @Override
+ public Object get(CompoundName name, Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ name = unalias(name, context);
+ Object value = null;
+ if (values != null)
+ value = values.get(name);
+ if (value == null) {
+ Pair<CompoundName, CompiledQueryProfile> reference = findReference(name);
+ if (reference != null)
+ return reference.getSecond().get(name.rest(reference.getFirst().size()), context, substitution); // yes; even if null
+ }
+
+ if (value == null)
+ value = profile.get(name, context, substitution);
+ if (value == null)
+ value = super.get(name, context, substitution);
+ return value;
+ }
+
+ /**
+ * Sets a value in this query profile
+ *
+ * @throws IllegalArgumentException if this property cannot be set in the wrapped query profile
+ */
+ @Override
+ public void set(CompoundName name, Object value, Map<String,String> context) {
+ // TODO: Refactor
+ try {
+ name = unalias(name, context);
+
+ if (context == null)
+ context = Collections.emptyMap();
+
+ if ( ! profile.isOverridable(name, context)) return;
+
+ // Check runtime references
+ Pair<CompoundName, CompiledQueryProfile> runtimeReference = findReference(name);
+ if (runtimeReference != null && ! runtimeReference.getSecond().isOverridable(name.rest(runtimeReference.getFirst().size()), context))
+ return;
+
+ // Check types
+ if ( ! profile.getTypes().isEmpty()) {
+ for (int i = 0; i<name.size(); i++) {
+ QueryProfileType type = profile.getType(name.first(i), context);
+ if (type == null) continue;
+ String localName = name.get(i);
+ FieldDescription fieldDescription = type.getField(localName);
+ if (fieldDescription == null && type.isStrict())
+ throw new IllegalArgumentException("'" + localName + "' is not declared in " + type + ", and the type is strict");
+
+ // TODO: In addition to strictness, check legality along the way
+
+ if (i == name.size()-1 && fieldDescription != null) { // at the end of the path, check the assignment type
+ value = fieldDescription.getType().convertFrom(value, profile.getRegistry());
+ if (value == null)
+ throw new IllegalArgumentException("'" + value + "' is not a " + fieldDescription.getType().toInstanceDescription());
+ }
+ }
+ }
+
+ if (value instanceof String && value.toString().startsWith("ref:")) {
+ if (profile.getRegistry() == null)
+ throw new IllegalArgumentException("Runtime query profile references does not work when the " +
+ "QueryProfileProperties are constructed without a registry");
+ String queryProfileId = value.toString().substring(4);
+ value = profile.getRegistry().findQueryProfile(queryProfileId);
+ if (value == null)
+ throw new IllegalArgumentException("Query profile '" + queryProfileId + "' is not found");
+ }
+
+ if (value instanceof CompiledQueryProfile) { // this will be due to one of the two clauses above
+ if (references == null)
+ references = new ArrayList<>();
+ references.add(0, new Pair<>(name, (CompiledQueryProfile)value)); // references set later has precedence - put first
+ }
+ else {
+ if (values == null)
+ values = new HashMap<>();
+ values.put(name, value);
+ }
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Could not set '" + name + "' to '" + value + "': " + e.getMessage()); // TODO: Nest instead
+ }
+ }
+
+ @Override
+ public Map<String, Object> listProperties(CompoundName path, Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ path = unalias(path, context);
+ if (context == null) context = Collections.emptyMap();
+
+ Map<String, Object> properties = profile.listValues(path, context, substitution);
+
+ properties.putAll(super.listProperties(path, context, substitution));
+
+ if (references != null) {
+ for (Pair<CompoundName, CompiledQueryProfile> refEntry : references) {
+ if ( ! refEntry.getFirst().hasPrefix(path.first(Math.min(refEntry.getFirst().size(), path.size())))) continue;
+
+ CompoundName pathInReference;
+ CompoundName prefixToReferenceKeys;
+ if (refEntry.getFirst().size() > path.size()) {
+ pathInReference = CompoundName.empty;
+ prefixToReferenceKeys = refEntry.getFirst().rest(path.size());
+ }
+ else {
+ pathInReference = path.rest(refEntry.getFirst().size());
+ prefixToReferenceKeys = CompoundName.empty;
+ }
+ for (Map.Entry<String, Object> valueEntry : refEntry.getSecond().listValues(pathInReference, context, substitution).entrySet()) {
+ properties.put(prefixToReferenceKeys.append(new CompoundName(valueEntry.getKey())).toString(), valueEntry.getValue());
+ }
+ }
+
+ }
+
+ if (values != null) {
+ for (Map.Entry<CompoundName, Object> entry : values.entrySet()) {
+ if (entry.getKey().hasPrefix(path))
+ properties.put(entry.getKey().rest(path.size()).toString(), entry.getValue());
+ }
+ }
+
+ return properties;
+ }
+
+ public boolean isComplete(StringBuilder firstMissingName, Map<String,String> context) {
+ // Are all types reachable from this complete?
+ if ( ! reachableTypesAreComplete(CompoundName.empty, profile, firstMissingName, context))
+ return false;
+
+ // Are all runtime references in this complete?
+ if (references == null) return true;
+ for (Pair<CompoundName, CompiledQueryProfile> reference : references) {
+ if ( ! reachableTypesAreComplete(reference.getFirst(), reference.getSecond(), firstMissingName, context))
+ return false;
+ }
+
+ return true;
+ }
+
+ private boolean reachableTypesAreComplete(CompoundName prefix, CompiledQueryProfile profile, StringBuilder firstMissingName, Map<String,String> context) {
+ for (Map.Entry<CompoundName, DimensionalValue<QueryProfileType>> typeEntry : profile.getTypes().entrySet()) {
+ QueryProfileType type = typeEntry.getValue().get(context);
+ if (type == null) continue;
+ if ( ! typeIsComplete(prefix.append(typeEntry.getKey()), type, firstMissingName, context))
+ return false;
+ }
+ return true;
+ }
+
+ private boolean typeIsComplete(CompoundName prefix, QueryProfileType type, StringBuilder firstMissingName, Map<String,String> context) {
+ if (type == null) return true;
+ for (FieldDescription field : type.fields().values()) {
+ if ( ! field.isMandatory()) continue;
+
+ CompoundName fieldName = prefix.append(field.getName());
+ if ( get(fieldName, null) != null) continue;
+ if ( hasReference(fieldName)) continue;
+
+ if (profile.getReferences().get(fieldName, context) != null) continue;
+
+ if (firstMissingName != null)
+ firstMissingName.append(fieldName);
+ return false;
+ }
+ return true;
+ }
+
+ private boolean hasReference(CompoundName name) {
+ if (references == null) return false;
+ for (Pair<CompoundName, CompiledQueryProfile> reference : references)
+ if (reference.getFirst().equals(name))
+ return true;
+ return false;
+ }
+
+ private Pair<CompoundName, CompiledQueryProfile> findReference(CompoundName name) {
+ if (references == null) return null;
+ for (Pair<CompoundName, CompiledQueryProfile> entry : references) {
+ if (name.hasPrefix(entry.getFirst())) return entry;
+ }
+ return null;
+ }
+
+ CompoundName unalias(CompoundName name, Map<String,String> context) {
+ if (profile.getTypes().isEmpty()) return name;
+
+ CompoundName unaliasedName = name;
+ for (int i = 0; i<name.size(); i++) {
+ QueryProfileType type = profile.getType(name.first(i), context);
+ if (type == null) continue;
+ if (type.aliases() == null) continue; // TODO: Make never null
+ if (type.aliases().isEmpty()) continue;
+ String localName = name.get(i);
+ String unaliasedLocalName = type.unalias(localName);
+ unaliasedName = unaliasedName.set(i, unaliasedLocalName);
+ }
+ return unaliasedName;
+ }
+
+ @Override
+ public QueryProfileProperties clone() {
+ QueryProfileProperties clone = (QueryProfileProperties)super.clone();
+ if (this.values != null)
+ clone.values = PropertyMap.cloneMap(this.values);
+ return clone;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java
new file mode 100644
index 00000000000..a4bca752d18
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java
@@ -0,0 +1,89 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry;
+
+/**
+ * A set of query profiles. This also holds the query profile types as a dependent registry
+ *
+ * @author bratseth
+ */
+public class QueryProfileRegistry extends ComponentRegistry<QueryProfile> {
+
+ private QueryProfileTypeRegistry queryProfileTypeRegistry = new QueryProfileTypeRegistry();
+
+ /** The current default instance of this registry */
+ private static QueryProfileRegistry instance = new QueryProfileRegistry();
+
+ /** Register this type by its id */
+ public void register(QueryProfile profile) {
+ super.register(profile.getId(), profile);
+ }
+
+ /** Returns a query profile type by name, or null if not found */
+ public QueryProfileType getType(String type) {
+ return queryProfileTypeRegistry.getComponent(type);
+ }
+
+ /** Returns the type registry attached to this */
+ public QueryProfileTypeRegistry getTypeRegistry() { return queryProfileTypeRegistry; }
+
+ /**
+ * <p>Returns a query profile for the given request string, or null if a suitable one is not found.</p>
+ *
+ * The request string must be a valid {@link com.yahoo.component.ComponentId} or null.
+ *
+ * <p>
+ * If the string is null, the profile named "default" is returned, or null if that does not exists.
+ *
+ * <p>
+ * The version part (if any) is matched used the usual component version patching rules.
+ * If the name part matches a query profile name perfectly, that profile is returned.
+ * If not, and the name is a slash-separated path, the profile with the longest matching left sub-path
+ * which has a type which allows path mahting is used. If there is no such profile, null is returned.
+ */
+ public QueryProfile findQueryProfile(String idString) {
+ if (idString==null) return getComponent("default");
+ ComponentSpecification id=new ComponentSpecification(idString);
+ QueryProfile profile=getComponent(id);
+ if (profile!=null) return profile;
+
+ return findPathParentQueryProfile(new ComponentSpecification(idString));
+ }
+
+ private QueryProfile findPathParentQueryProfile(ComponentSpecification id) {
+ // Try the name with "/" appended - should have the same semantics with path matching
+ QueryProfile slashedProfile=getComponent(new ComponentSpecification(id.getName() + "/",id.getVersionSpecification()));
+ if (slashedProfile!=null && slashedProfile.getType()!=null && slashedProfile.getType().getMatchAsPath())
+ return slashedProfile;
+
+ // Extract the parent (if any)
+ int slashIndex=id.getName().lastIndexOf("/");
+ if (slashIndex<1) return null;
+ String parentName=id.getName().substring(0,slashIndex);
+ if (parentName.equals("")) return null;
+
+ ComponentSpecification parentId=new ComponentSpecification(parentName,id.getVersionSpecification());
+
+ QueryProfile pathParentProfile=getComponent(parentId);
+
+ if (pathParentProfile!=null && pathParentProfile.getType()!=null && pathParentProfile.getType().getMatchAsPath())
+ return pathParentProfile;
+ return findPathParentQueryProfile(parentId);
+ }
+
+ /** Freezes this, and all owned query profiles and query profile types */
+ public @Override void freeze() {
+ if (isFrozen()) return;
+ queryProfileTypeRegistry.freeze();
+ for (QueryProfile queryProfile : allComponents())
+ queryProfile.freeze();
+ }
+
+ public CompiledQueryProfileRegistry compile() { return QueryProfileCompiler.compile(this); }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java
new file mode 100644
index 00000000000..42ea4a96d8f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java
@@ -0,0 +1,157 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.*;
+
+/**
+ * A variant of a query profile
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+*/
+public class QueryProfileVariant implements Cloneable, Comparable<QueryProfileVariant> {
+
+ private List<QueryProfile> inherited=null;
+
+ private DimensionValues dimensionValues;
+
+ private Map<String,Object> values;
+
+ private boolean frozen=false;
+
+ private QueryProfile owner;
+
+ public QueryProfileVariant(DimensionValues dimensionValues, QueryProfile owner) {
+ this.dimensionValues=dimensionValues;
+ this.owner = owner;
+ }
+
+ public DimensionValues getDimensionValues() { return dimensionValues; }
+
+ /**
+ * Returns the live reference to the values of this. This may be modified
+ * if this is not frozen.
+ */
+ public Map<String,Object> values() {
+ if (values==null) {
+ if (frozen)
+ return Collections.emptyMap();
+ else
+ values=new HashMap<>();
+ }
+ return values;
+ }
+
+ /**
+ * Returns the live reference to the inherited profiles of this. This may be modified
+ * if this is not frozen.
+ */
+ public List<QueryProfile> inherited() {
+ if (inherited==null) {
+ if (frozen)
+ return Collections.emptyList();
+ else
+ inherited=new ArrayList<>();
+ }
+ return inherited;
+ }
+
+ public void set(String key, Object newValue) {
+ if (values==null)
+ values=new HashMap<>();
+
+ Object oldValue = values.get(key);
+
+ if (oldValue == null) {
+ values.put(key, newValue);
+ } else {
+ Object combinedOrNull = QueryProfile.combineValues(newValue, oldValue);
+ if (combinedOrNull != null) {
+ values.put(key, combinedOrNull);
+ }
+ }
+ }
+
+ public void inherit(QueryProfile profile) {
+ if (inherited==null)
+ inherited=new ArrayList<>(1);
+ inherited.add(profile);
+ }
+
+ /**
+ * Implements the sort order of this which is based on specificity
+ * where dimensions to the left are more significant.
+ * <p>
+ * <b>Note:</b> This ordering is not consistent with equals - it returns 0 when the same dimensions
+ * are <i>set</i>, regardless of what they are set <i>to</i>.
+ */
+ public @Override int compareTo(QueryProfileVariant other) {
+ return this.dimensionValues.compareTo(other.dimensionValues);
+ }
+
+ public boolean matches(DimensionValues givenDimensionValues) {
+ return this.dimensionValues.matches(givenDimensionValues);
+ }
+
+ /** Accepts a visitor to the values of this */
+ public void accept(boolean allowContent,QueryProfileType type,QueryProfileVisitor visitor, DimensionBinding dimensionBinding) {
+ // Visit this
+ if (allowContent) {
+ String key=visitor.getLocalKey();
+ if (key!=null) {
+ if (type!=null)
+ type.unalias(key);
+
+ visitor.acceptValue(key, values().get(key), dimensionBinding, owner);
+ if (visitor.isDone()) return;
+ }
+ else {
+ for (Map.Entry<String,Object> entry : values().entrySet()) {
+ visitor.acceptValue(entry.getKey(), entry.getValue(), dimensionBinding, owner);
+ if (visitor.isDone()) return;
+ }
+ }
+ }
+
+ // Visit inherited
+ for (QueryProfile profile : inherited()) {
+ if (visitor.visitInherited()) {
+ profile.accept(allowContent,visitor,dimensionBinding.createFor(profile.getDimensions()), owner);
+ }
+ if (visitor.isDone()) return;
+ }
+ }
+
+ public void freeze() {
+ if (frozen) return;
+ if (inherited != null)
+ inherited = ImmutableList.copyOf(inherited);
+ if (values != null)
+ values = ImmutableMap.copyOf(values);
+ frozen=true;
+ }
+
+ public QueryProfileVariant clone() {
+ if (frozen) return this;
+ try {
+ QueryProfileVariant clone=(QueryProfileVariant)super.clone();
+ if (this.inherited!=null)
+ clone.inherited=new ArrayList<>(this.inherited); // TODO: Deep clone is more correct, but probably does not matter in practice
+
+ clone.values=CopyOnWriteContent.deepClone(this.values);
+
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public @Override String toString() {
+ return "query profile variant for " + dimensionValues;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java
new file mode 100644
index 00000000000..fde851bdc75
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java
@@ -0,0 +1,486 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.component.provider.Freezable;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.*;
+
+/**
+ * This class represent a set of query profiles virtually - rather
+ * than storing and instantiating each profile this structure represents explicitly only
+ * the values set in the various virtual profiles. The set of virtual profiles are defined by a set of
+ * <i>dimensions</i>. Values may be set for any point in this multi-dimensional space, and may also be set for
+ * any regular hyper-region by setting values for any point in certain of these dimensions.
+ * The set of virtual profiles defined by this consists of all the combinations of dimension points for
+ * which one or more values is set in this, as well as any possible less specified regions.
+ * <p>
+ * A set of virtual profiles are always owned by a single profile, which is also their parent
+ * in the inheritance hierarchy.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class QueryProfileVariants implements Freezable, Cloneable {
+
+ private boolean frozen=false;
+
+ /** Properties indexed by name, to support fast lookup of single values */
+ private Map<String,FieldValues> fieldValuesByName=new HashMap<>();
+
+ /** The inherited profiles for various dimensions settings - a set of fieldvalues of List&lt;QueryProfile&gt; */
+ private FieldValues inheritedProfiles=new FieldValues();
+
+ /**
+ * Field and inherited profiles sorted by specificity used for all-value visiting.
+ * This is the same as how the source data looks (apart from the sorting).
+ */
+ private List<QueryProfileVariant> variants=new ArrayList<>();
+
+ /**
+ * The names of the dimensions (which are possible properties in the context given on lookup) of this.
+ * Order matters - more specific values to the left in this list are more significant than more specific values
+ * to the right
+ */
+ private List<String> dimensions;
+
+ /** The query profile this variants of */
+ private QueryProfile owner;
+
+ /**
+ * Creates a set of virtual query profiles which may return varying values over the set of dimensions given.
+ * Each dimension is a name for which a key-value may be supplied in the context properties
+ * on lookup time to influence the value returned.
+ */
+ public QueryProfileVariants(String[] dimensions, QueryProfile owner) {
+ this(Arrays.asList(dimensions), owner);
+ }
+
+ /**
+ * Creates a set of virtual query profiles which may return varying values over the set of dimensions given.
+ * Each dimension is a name for which a key-value may be supplied in the context properties
+ * on lookup time to influence the value returned.
+ *
+ * @param dimensions the dimension names this may vary over. The list gets owned by this, so it must not be further
+ * modified from outside). This will not modify the list.
+ */
+ public QueryProfileVariants(List<String> dimensions, QueryProfile owner) {
+ // Note: This is not made unmodifiable (here or in freeze) because we depend on map identity comparisons of this
+ // list (in dimensionBinding) for performance reasons.
+ this.dimensions = dimensions;
+ this.owner = owner;
+ }
+
+ /** Irreversibly prevents any further modifications to this */
+ public void freeze() {
+ if (frozen) return;
+ for (FieldValues fieldValues : fieldValuesByName.values())
+ fieldValues.freeze();
+ fieldValuesByName = ImmutableMap.copyOf(fieldValuesByName);
+ inheritedProfiles.freeze();
+
+ Collections.sort(variants);
+ for (QueryProfileVariant variant : variants)
+ variant.freeze();
+ variants = ImmutableList.copyOf(variants);
+
+ frozen=true;
+ }
+
+ @Override
+ public boolean isFrozen() {
+ return frozen;
+ }
+
+ /** Visits the most specific match to the dimension binding of each variable (or the one named by the visitor) */
+ void accept(boolean allowContent,QueryProfileType type,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) {
+ String contentName=null;
+ if (allowContent)
+ contentName=visitor.getLocalKey();
+
+ if (contentName!=null) {
+ if (type!=null)
+ contentName=type.unalias(contentName);
+ acceptSingleValue(contentName,allowContent,visitor,dimensionBinding); // Special cased for performance
+ }
+ else {
+ acceptAllValues(allowContent,visitor,type,dimensionBinding);
+ }
+ }
+
+ // PERF: 90%
+ void acceptSingleValue(String name,boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) {
+ FieldValues fieldValues=fieldValuesByName.get(name);
+ if (fieldValues==null || !allowContent)
+ fieldValues=new FieldValues();
+
+ fieldValues.sort();
+ inheritedProfiles.sort();
+
+ int inheritedIndex=0;
+ int fieldIndex=0;
+ // Go through both the fields and the inherited profiles at the same time and try the single must specific pick
+ // from either of the lists at each step
+ while(fieldIndex<fieldValues.size() || inheritedIndex<inheritedProfiles.size()) { // PERF: 8% - fieldValues.size()
+ // Get the next most specific from field and inherited
+ FieldValue fieldValue=fieldValues.getIfExists(fieldIndex); // PERF: 11% - getIfExists
+ FieldValue inheritedProfileValue=inheritedProfiles.getIfExists(inheritedIndex); // PERF: 11% - getIfExists
+
+ // Try the most specific first, then the other
+ if (inheritedProfileValue==null || (fieldValue!=null && fieldValue.compareTo(inheritedProfileValue)<=0)) { // Field is most specific, or both are equally specific
+ if (fieldValue.matches(dimensionBinding.getValues())) { // PERF: 42% - matches, together with the other matches
+ visitor.acceptValue(name, fieldValue.getValue(), dimensionBinding, owner);
+ }
+ if (visitor.isDone()) return;
+ fieldIndex++;
+ }
+ else if (inheritedProfileValue!=null) { // Inherited is most specific at this point
+ if (inheritedProfileValue.matches(dimensionBinding.getValues())) { // PERF: 42% - matches, together with the other matches
+ @SuppressWarnings("unchecked")
+ List<QueryProfile> inheritedProfileList=(List<QueryProfile>)inheritedProfileValue.getValue();
+ for (QueryProfile inheritedProfile : inheritedProfileList) {
+ if (visitor.visitInherited()) {
+ inheritedProfile.accept(allowContent,visitor,dimensionBinding.createFor(inheritedProfile.getDimensions()), owner);
+ }
+ if (visitor.isDone()) return;
+ }
+ }
+ inheritedIndex++;
+ }
+ if (visitor.isDone()) return;
+ }
+ }
+
+ void acceptAllValues(boolean allowContent,QueryProfileVisitor visitor, QueryProfileType type,DimensionBinding dimensionBinding) {
+ if (!frozen)
+ Collections.sort(variants);
+ for (QueryProfileVariant variant : variants) {
+ if (variant.matches(dimensionBinding.getValues()))
+ variant.accept(allowContent,type,visitor,dimensionBinding);
+ if (visitor.isDone()) return;
+ }
+ }
+
+ /**
+ * Returns the most specific matching value of a name for a given set of <b>canonical</b> dimension values.
+ *
+ * @param name the name to return the best matching value of
+ * @param dimensionBinding the dimension bindings to use in this
+ */
+ public Object get(String name, QueryProfileType type, boolean allowQueryProfileResult, DimensionBinding dimensionBinding) {
+ SingleValueQueryProfileVisitor visitor=new SingleValueQueryProfileVisitor(Collections.singletonList(name),allowQueryProfileResult);
+ visitor.enter("");
+ accept(true,type,visitor,dimensionBinding);
+ visitor.leave("");
+ return visitor.getResult();
+ }
+
+ /** Inherits a particular profile in a variant of this */
+ public void inherit(QueryProfile profile,DimensionValues dimensionValues) {
+ ensureNotFrozen();
+
+ // Update variant
+ getVariant(dimensionValues,true).inherit(profile);
+
+ // Update per-variable optimized structure
+ @SuppressWarnings("unchecked")
+ List<QueryProfile> inheritedAtDimensionValues=(List<QueryProfile>)inheritedProfiles.getExact(dimensionValues);
+ if (inheritedAtDimensionValues==null) {
+ inheritedAtDimensionValues=new ArrayList<>();
+ inheritedProfiles.put(dimensionValues,inheritedAtDimensionValues);
+ }
+ inheritedAtDimensionValues.add(profile);
+ }
+
+ /**
+ * Sets a value to this
+ *
+ * @param fieldName the name of the field to set. This cannot be a compound (dotted) name
+ * @param binding the dimension values for which this value applies.
+ * The dimensions must be canonicalized, and ownership is transferred to this.
+ * @param value the value to set
+ */
+ /**
+ * Sets a value to this
+ *
+ * @param fieldName the name of the field to set. This cannot be a compound (dotted) name
+ * @param dimensionValues the dimension values for which this value applies
+ * @param value the value to set
+ */
+ public void set(String fieldName,DimensionValues dimensionValues,Object value) {
+ ensureNotFrozen();
+
+ // Update variant
+ getVariant(dimensionValues,true).set(fieldName,value);
+
+ // Update per-variable optimized structure
+ FieldValues fieldValues=fieldValuesByName.get(fieldName);
+ if (fieldValues==null) {
+ fieldValues=new FieldValues();
+ fieldValuesByName.put(fieldName,fieldValues);
+ }
+
+ Object combinedValue=QueryProfile.combineValues(value,fieldValues.getExact(dimensionValues));
+ if (combinedValue!=null)
+ fieldValues.put(dimensionValues,combinedValue);
+ }
+
+ /**
+ * Returns the dimensions over which the virtual profiles in this may return different values.
+ * Each dimension is a name for which a key-value may be supplied in the context properties
+ * on lookup time to influence the value returned.
+ * The dimensions may not be modified - the returned list is always read only.
+ */
+ // Note: A performance optimization in DimensionBinding depends on the identity of the list returned from this
+ public List<String> getDimensions() { return dimensions; }
+
+ /** Returns the map of field values of this indexed by field name. */
+ public Map<String,FieldValues> getFieldValues() { return fieldValuesByName; }
+
+ /** Returns the profiles inherited from various variants of this */
+ public FieldValues getInherited() { return inheritedProfiles; }
+
+ /**
+ * Returns all the variants of this, sorted by specificity. This is content as declared.
+ * The returned list is always unmodifiable.
+ */
+ public List<QueryProfileVariant> getVariants() {
+ if (frozen) return variants; // Already unmodifiable
+ return Collections.unmodifiableList(variants);
+ }
+
+ public QueryProfileVariants clone() {
+ try {
+ if (frozen) return this;
+ QueryProfileVariants clone=(QueryProfileVariants)super.clone();
+ clone.inheritedProfiles=inheritedProfiles.clone();
+
+ clone.variants=new ArrayList<>();
+ for (QueryProfileVariant variant : variants)
+ clone.variants.add(variant.clone());
+
+ clone.fieldValuesByName=new HashMap<>();
+ for (Map.Entry<String,FieldValues> entry : fieldValuesByName.entrySet())
+ clone.fieldValuesByName.put(entry.getKey(),entry.getValue().clone(entry.getKey(),clone.variants));
+
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Throws an IllegalStateException if this is frozen */
+ protected void ensureNotFrozen() {
+ if (frozen)
+ throw new IllegalStateException(this + " is frozen and cannot be modified");
+ }
+
+ /**
+ * Returns the query profile variant having exactly the given dimensions, and creates it if create is set and
+ * it is missing
+ *
+ * @param dimensionValues the dimension values
+ * @param create whether or not to create the variant if missing
+ * @return the profile variant, or null if not found and create is false
+ */
+ public QueryProfileVariant getVariant(DimensionValues dimensionValues,boolean create) {
+ for (QueryProfileVariant profileVariant : variants)
+ if (profileVariant.getDimensionValues().equals(dimensionValues))
+ return profileVariant;
+
+ // Not found
+ if (!create) return null;
+ QueryProfileVariant variant=new QueryProfileVariant(dimensionValues, owner);
+ variants.add(variant);
+ return variant;
+ }
+
+ public static class FieldValues implements Freezable, Cloneable {
+
+ private List<FieldValue> resolutionList=null;
+
+ private boolean frozen=false;
+
+ @Override
+ public void freeze() {
+ if (frozen) return;
+ sort();
+ if (resolutionList != null)
+ resolutionList = ImmutableList.copyOf(resolutionList);
+ frozen = true;
+ }
+
+ @Override
+ public boolean isFrozen() {
+ return frozen;
+ }
+
+ public void put(DimensionValues dimensionValues,Object value) {
+ ensureNotFrozen();
+ if (resolutionList==null) resolutionList=new ArrayList<>();
+ FieldValue fieldValue=getExactFieldValue(dimensionValues);
+ if (fieldValue!=null) // Replace
+ fieldValue.setValue(value);
+ else
+ resolutionList.add(new FieldValue(dimensionValues,value));
+ }
+
+ /** Returns the value having exactly the given dimensions, or null if none */
+ public Object getExact(DimensionValues dimensionValues) {
+ FieldValue value=getExactFieldValue(dimensionValues);
+ if (value==null) return null;
+ return value.getValue();
+ }
+
+ /** Returns the field value having exactly the given dimensions, or null if none */
+ private FieldValue getExactFieldValue(DimensionValues dimensionValues) {
+ for (FieldValue fieldValue : asList())
+ if (fieldValue.getDimensionValues().equals(dimensionValues))
+ return fieldValue;
+ return null;
+ }
+
+ /** Returns the field values (values for various dimensions) for this field as a read-only list (never null) */
+ public List<FieldValue> asList() {
+ if (resolutionList==null) return Collections.emptyList();
+ return resolutionList;
+ }
+
+ public FieldValue getIfExists(int index) {
+ if (index>=size()) return null;
+ return resolutionList.get(index);
+ }
+
+ public void sort() {
+ if (frozen) return ; // sorted already
+ if (resolutionList!=null)
+ Collections.sort(resolutionList);
+ }
+
+ /** Same as asList().size() */
+ public int size() {
+ if (resolutionList==null) return 0;
+ return resolutionList.size();
+ }
+
+ /** Throws an IllegalStateException if this is frozen */
+ protected void ensureNotFrozen() {
+ if (frozen)
+ throw new IllegalStateException(this + " is frozen and cannot be modified");
+ }
+
+ /** Clone by filling in values from the given variants */
+ public FieldValues clone(String fieldName,List<QueryProfileVariant> clonedVariants) {
+ try {
+ if (frozen) return this;
+ FieldValues clone=(FieldValues)super.clone();
+
+ if (resolutionList!=null) {
+ clone.resolutionList=new ArrayList<>(resolutionList.size());
+ for (FieldValue value : resolutionList)
+ clone.resolutionList.add(value.clone(fieldName,clonedVariants));
+ }
+
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public @Override FieldValues clone() {
+ try {
+ if (frozen) return this;
+ FieldValues clone=(FieldValues)super.clone();
+
+ if (resolutionList!=null) {
+ clone.resolutionList=new ArrayList<>(resolutionList.size());
+ for (FieldValue value : resolutionList)
+ clone.resolutionList.add(value.clone());
+ }
+
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ }
+
+ public static class FieldValue implements Comparable<FieldValue>, Cloneable {
+
+ private DimensionValues dimensionValues;
+ private Object value;
+
+ public FieldValue(DimensionValues dimensionValues,Object value) {
+ this.dimensionValues=dimensionValues;
+ this.value=value;
+ }
+
+ /**
+ * Returns the dimension values for which this value should be used.
+ * The dimension array is always of the exact size of the dimensions specified by the owning QueryProfileVariants,
+ * and the values appear in the order defined. "Wildcard" dimensions are represented by a null.
+ */
+ public DimensionValues getDimensionValues() { return dimensionValues; }
+
+ /** Returns the value to use for this set of dimension values */
+ public Object getValue() { return value; }
+
+ /** Sets the value to use for this set of dimension values */
+ public void setValue(Object value) { this.value=value; }
+
+ public boolean matches(DimensionValues givenDimensionValues) {
+ return dimensionValues.matches(givenDimensionValues);
+ }
+
+ /**
+ * Implements the sort order of this which is based on specificity
+ * where dimensions to the left are more significant.
+ * <p>
+ * <b>Note:</b> This ordering is not consistent with equals - it returns 0 when the same dimensions
+ * are <i>set</i>, regardless of what they are set <i>to</i>.
+ */
+ public @Override int compareTo(FieldValue other) {
+ return this.dimensionValues.compareTo(other.dimensionValues);
+ }
+
+ /** Clone by filling in the value from the given variants */
+ public FieldValue clone(String fieldName,List<QueryProfileVariant> clonedVariants) {
+ try {
+ FieldValue clone=(FieldValue)super.clone();
+ if (this.value instanceof QueryProfile)
+ clone.value=lookupInVariants(fieldName,dimensionValues,clonedVariants);
+ // Otherwise the value is immutable, so keep it as-is
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public FieldValue clone() {
+ try {
+ FieldValue clone=(FieldValue)super.clone();
+ clone.value=QueryProfile.cloneIfNecessary(this.value);
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private Object lookupInVariants(String fieldName,DimensionValues dimensionValues,List<QueryProfileVariant> variants) {
+ for (QueryProfileVariant variant : variants) {
+ if ( ! variant.getDimensionValues().equals(dimensionValues)) continue;
+ return variant.values().get(fieldName);
+ }
+ return null;
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java
new file mode 100644
index 00000000000..8cb6bf34021
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java
@@ -0,0 +1,87 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+/**
+ * Instances of this is used to visit nodes in a graph of query profiles
+ *
+ * <code>
+ * Visitor are called in the following sequence on each query profile:
+ * enter=enter(referenceName);
+ * onQueryProfile(this)
+ * if (enter) {
+ * getLocalKey()
+ * ...calls on nested content found in variants, this and inherited, in that order
+ * leave(referenceName)
+ * }
+ *
+ * The first enter call will be on the root node, which has an empt reference name.
+ * </code>
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+abstract class QueryProfileVisitor {
+
+ /**
+ * Called when a new <b>nested</b> profile in the graph is entered.
+ * This default implementation does nothing but returning true.
+ * If the node is entered (if true is returned from this), a corresponding {@link #leave(String)} call will happen
+ * later.
+ *
+ * @param name the name this profile is nested as, or the empty string if we are entering the root profile
+ * @return whether we should visit the content of this node or not
+ */
+ public boolean enter(String name) { return true; }
+
+ /**
+ * Called when the last {@link #enter(String) entered} nested profile is left.
+ * That is: One leave call is made for each enter call which returns true,
+ * but due to nesting those calls are not necessarily alternating.
+ * This default implementation does nothing.
+ */
+ public void leave(String name) { }
+
+ /**
+ * Called when a value (not a query profile) is encountered.
+ *
+ * @param localName the local name of this value (the full name, if needed, must be reconstructed
+ * by the information given by the history of {@link #enter(String)} and {@link #leave(String)} calls
+ * @param value the value
+ * @param binding the binding this holds for
+ * @param owner the query profile having this value, or null only when profile is the root profile
+ */
+ public abstract void onValue(String localName, Object value, DimensionBinding binding, QueryProfile owner);
+
+ /**
+ * Called when a query profile is encountered.
+ *
+ * @param profile the query profile reference encountered
+ * @param binding the binding this holds for
+ * @param owner the profile making this reference, or null only when profile is the root profile
+ */
+ public abstract void onQueryProfile(QueryProfile profile, DimensionBinding binding, QueryProfile owner);
+
+ /** Returns whether this visitor is done visiting what it needed to visit at this point */
+ public abstract boolean isDone();
+
+ /** Returns whether we should, at this point, visit inherited profiles. This default implementation returns true */
+ public boolean visitInherited() { return true; }
+
+ /**
+ * Returns the current local key which should be visited in the last {@link #enter(String) entered} sub-profile
+ * (or in the top level profile if none is entered), or null to visit all content
+ */
+ public abstract String getLocalKey();
+
+ /** Calls onValue or onQueryProfile on this and visits the content if it's a profile */
+ final void acceptValue(String key, Object value, DimensionBinding dimensionBinding, QueryProfile owner) {
+ if (value==null) return;
+ if (value instanceof QueryProfile) {
+ QueryProfile queryProfileValue=(QueryProfile)value;
+ queryProfileValue.acceptAndEnter(key, this, dimensionBinding.createFor(queryProfileValue.getDimensions()), owner);
+ }
+ else {
+ onValue(key, value, dimensionBinding, owner);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java
new file mode 100644
index 00000000000..6d5d1b0686a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java
@@ -0,0 +1,76 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import java.util.List;
+
+/**
+ * Visitor which stores the first non-query-profile value encountered,
+ * or the first query profile encountered at a stop where we do not have any name components left which can be used to
+ * visit further subprofiles. Hence this may be used both to get the highest prioritized primitive
+ * value, or query profile, whichever is encountered first which matches the name.
+ * <p>
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+final class SingleValueQueryProfileVisitor extends QueryProfileVisitor {
+
+ /** The value found, or null if none */
+ private Object value=null;
+
+ private final List<String> name;
+
+ private int nameIndex=-1;
+
+ private final boolean allowQueryProfileResult;
+
+ private boolean enteringContent=true;
+
+ public SingleValueQueryProfileVisitor(List<String> name,boolean allowQueryProfileResult) {
+ this.name=name;
+ this.allowQueryProfileResult=allowQueryProfileResult;
+ }
+
+ public @Override String getLocalKey() {
+ return name.get(nameIndex);
+ }
+
+ public @Override boolean enter(String name) {
+ if (nameIndex+1<this.name.size()) {
+ nameIndex++;
+ enteringContent=true;
+ }
+ else {
+ enteringContent=false;
+ }
+ return enteringContent;
+ }
+
+ public @Override void leave(String name) {
+ nameIndex--;
+ }
+
+ public @Override void onValue(String key,Object value, DimensionBinding binding, QueryProfile owner) {
+ if (nameIndex==name.size()-1)
+ this.value=value;
+ }
+
+ public @Override void onQueryProfile(QueryProfile profile,DimensionBinding binding, QueryProfile owner) {
+ if (enteringContent) return; // still waiting for content
+ if (allowQueryProfileResult)
+ this.value = profile;
+ else
+ this.value = profile.getValue();
+ }
+
+ public @Override boolean isDone() {
+ return value!=null;
+ }
+
+ /** Returns the value found during visiting, or null if none */
+ public Object getResult() { return value; }
+
+ public @Override String toString() {
+ return "a single value visitor (hash " + hashCode() + ") with current value " + value;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java b/container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java
new file mode 100644
index 00000000000..59401592378
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java
@@ -0,0 +1,127 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile;
+
+import com.yahoo.processing.request.Properties;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A string which contains one or more elements of the form %{name},
+ * where these occurrences are to be replaced by a query profile lookup on name.
+ * <p>
+ * This objects does the analysis on creation and provides a (reasonably) fast method of
+ * performing the actual substitution (at lookup time).
+ * <p>
+ * This is a value object. Lookups in this are thread safe.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class SubstituteString {
+
+ private final List<Component> components;
+ private final String stringValue;
+
+ /**
+ * Returns a new SubstituteString if the given string contains substitutions, null otherwise.
+ */
+ public static SubstituteString create(String value) {
+ int lastEnd=0;
+ int start=value.indexOf("%{");
+ if (start<0) return null; // Shortcut
+ List<Component> components=new ArrayList<>();
+ while (start>=0) {
+ int end=value.indexOf("}",start+2);
+ if (end<0)
+ throw new IllegalArgumentException("Unterminated value substitution '" + value.substring(start) + "'");
+ String propertyName=value.substring(start+2,end);
+ if (propertyName.indexOf("%{")>=0)
+ throw new IllegalArgumentException("Unterminated value substitution '" + value.substring(start) + "'");
+ components.add(new StringComponent(value.substring(lastEnd,start)));
+ components.add(new PropertyComponent(propertyName));
+ lastEnd=end+1;
+ start=value.indexOf("%{",lastEnd);
+ }
+ components.add(new StringComponent(value.substring(lastEnd,value.length())));
+ return new SubstituteString(components, value);
+ }
+
+ private SubstituteString(List<Component> components, String stringValue) {
+ this.components = components;
+ this.stringValue = stringValue;
+ }
+
+ /**
+ * Perform the substitution in this, by looking up in the given query profile,
+ * and returns the resulting string
+ */
+ public String substitute(Map<String,String> context,Properties substitution) {
+ StringBuilder b=new StringBuilder();
+ for (Component component : components)
+ b.append(component.getValue(context,substitution));
+ return b.toString();
+ }
+
+ @Override
+ public int hashCode() {
+ return stringValue.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) return true;
+ if ( ! (other instanceof SubstituteString)) return false;
+ return this.stringValue.equals(((SubstituteString)other).stringValue);
+ }
+
+ /** Returns this string in original (unsubstituted) form */
+ public @Override String toString() {
+ return stringValue;
+ }
+
+ private abstract static class Component {
+
+ protected abstract String getValue(Map<String,String> context,Properties substitution);
+
+ }
+
+ private final static class StringComponent extends Component {
+
+ private final String value;
+
+ public StringComponent(String value) {
+ this.value=value;
+ }
+
+ public @Override String getValue(Map<String,String> context,Properties substitution) {
+ return value;
+ }
+
+ public @Override String toString() {
+ return value;
+ }
+
+ }
+
+ private final static class PropertyComponent extends Component {
+
+ private final String propertyName;
+
+ public PropertyComponent(String propertyName) {
+ this.propertyName=propertyName;
+ }
+
+ public @Override String getValue(Map<String,String> context,Properties substitution) {
+ Object value=substitution.get(propertyName,context,substitution);
+ if (value==null) return "";
+ return String.valueOf(value);
+ }
+
+ public @Override String toString() {
+ return "%{" + propertyName + "}";
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java
new file mode 100644
index 00000000000..a440365ceba
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java
@@ -0,0 +1,128 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.compiled;
+
+import com.yahoo.search.query.profile.DimensionBinding;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * An immutable binding of a set of dimensions to values.
+ * This binding is minimal in that it only includes dimensions which actually have values.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class Binding implements Comparable<Binding> {
+
+ private static final int maxDimensions = 31;
+
+ /**
+ * A higher number means this is more general. This accounts for both the number and position of the bindings
+ * in the dimensional space, such that bindings in earlier dimensions are matched before bindings in
+ * later dimensions
+ */
+ private final int generality;
+
+ /** The dimensions of this. Unenforced invariant: Content never changes. */
+ private final String[] dimensions;
+
+ /** The values of those dimensions. Unenforced invariant: Content never changes. */
+ private final String[] dimensionValues;
+
+ private final int hashCode;
+
+ @SuppressWarnings("unchecked")
+ public static final Binding nullBinding= new Binding(Integer.MAX_VALUE, Collections.<String,String>emptyMap());
+
+ public static Binding createFrom(DimensionBinding dimensionBinding) {
+ if (dimensionBinding.getDimensions().size() > maxDimensions)
+ throw new IllegalArgumentException("More than 31 dimensions is not supported");
+
+ int generality = 0;
+ Map<String, String> context = new HashMap<>();
+ if (dimensionBinding.getDimensions() == null || dimensionBinding.getDimensions().isEmpty()) { // TODO: Just have this return the nullBinding
+ generality = Integer.MAX_VALUE;
+ }
+ else {
+ for (int i = 0; i <= maxDimensions; i++) {
+ String value = i < dimensionBinding.getDimensions().size() ? dimensionBinding.getValues().get(i) : null;
+ if (value == null)
+ generality += Math.pow(2, maxDimensions - i-1);
+ else
+ context.put(dimensionBinding.getDimensions().get(i), value);
+ }
+ }
+ return new Binding(generality, context);
+ }
+
+ private Binding(int generality, Map<String, String> binding) {
+ this.generality = generality;
+
+ // Map -> arrays to limit memory consumption and speed up evaluation
+ dimensions = new String[binding.size()];
+ dimensionValues = new String[binding.size()];
+
+ int i = 0;
+ int bindingHash = 0;
+ for (Map.Entry<String,String> entry : binding.entrySet()) {
+ dimensions[i] = entry.getKey();
+ dimensionValues[i] = entry.getValue();
+ bindingHash += i * entry.getKey().hashCode() + 11 * i * entry.getValue().hashCode();
+ i++;
+ }
+ this.hashCode = bindingHash;
+ }
+
+ /** Returns true only if this binding is null (contains no values for its dimensions (if any) */
+ public boolean isNull() { return dimensions.length == 0; }
+
+ @Override
+ public String toString() {
+ StringBuilder b = new StringBuilder("Binding[");
+ for (int i = 0; i < dimensions.length; i++)
+ b.append(dimensions[i]).append("=").append(dimensionValues[i]).append(",");
+ if (dimensions.length > 0)
+ b.setLength(b.length()-1);
+ b.append("] (generality " + generality + ")");
+ return b.toString();
+ }
+
+ /** Returns whether the given binding has exactly the same values as this */
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (! (o instanceof Binding)) return false;
+ Binding other = (Binding)o;
+ return Arrays.equals(this.dimensions, other.dimensions)
+ && Arrays.equals(this.dimensionValues, other.dimensionValues);
+ }
+
+ @Override
+ public int hashCode() { return hashCode; }
+
+ /**
+ * Returns true if all the dimension values in this have the same values
+ * in the given context.
+ */
+ public boolean matches(Map<String,String> context) {
+ for (int i = 0; i < dimensions.length; i++) {
+ if ( ! dimensionValues[i].equals(context.get(dimensions[i]))) return false;
+ }
+ return true;
+ }
+
+ /**
+ * Implements a partial ordering where more specific bindings come before less specific ones,
+ * taking both the number of bindings and their positions into account (earlier dimensions
+ * take precedence over later ones.
+ * <p>
+ * The order is not well defined for bindings in different dimensional spaces.
+ */
+ @Override
+ public int compareTo(Binding other) {
+ return Integer.compare(this.generality, other.generality);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java
new file mode 100644
index 00000000000..a4056ee55a2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java
@@ -0,0 +1,183 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.compiled;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.component.ComponentId;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.processing.request.Properties;
+import com.yahoo.search.query.profile.QueryProfileProperties;
+import com.yahoo.search.query.profile.SubstituteString;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A query profile in a state where it is optimized for fast lookups.
+ *
+ * @author bratseth
+ */
+public class CompiledQueryProfile extends AbstractComponent implements Cloneable {
+
+ private static final Pattern namePattern=Pattern.compile("[$a-zA-Z_/][-$a-zA-Z0-9_/()]*");
+
+ private final CompiledQueryProfileRegistry registry;
+
+ /** The type of this, or null if none */
+ private final QueryProfileType type;
+
+ /** The values of this */
+ private final DimensionalMap<CompoundName, Object> entries;
+
+ /** Keys which have a type in this */
+ private final DimensionalMap<CompoundName, QueryProfileType> types;
+
+ /** Keys which are (typed or untyped) references to other query profiles in this. Used as a set. */
+ private final DimensionalMap<CompoundName, Object> references;
+
+ /** Values which are not overridable in this. Used as a set. */
+ private final DimensionalMap<CompoundName, Object> unoverridables;
+
+ /**
+ * Creates a new query profile from an id.
+ */
+ public CompiledQueryProfile(ComponentId id, QueryProfileType type,
+ DimensionalMap<CompoundName, Object> entries,
+ DimensionalMap<CompoundName, QueryProfileType> types,
+ DimensionalMap<CompoundName, Object> references,
+ DimensionalMap<CompoundName, Object> unoverridables,
+ CompiledQueryProfileRegistry registry) {
+ super(id);
+ this.registry = registry;
+ if (type != null)
+ type.freeze();
+ this.type = type;
+ this.entries = entries;
+ this.types = types;
+ this.references = references;
+ this.unoverridables = unoverridables;
+ if ( ! id.isAnonymous())
+ validateName(id.getName());
+ }
+
+ // ----------------- Public API -------------------------------------------------------------------------------
+
+ /** Returns the registry this belongs to, or null if none (in which case runtime profile reference assignment won't work) */
+ public CompiledQueryProfileRegistry getRegistry() { return registry; }
+
+ /** Returns the type of this or null if it has no type */
+ // TODO: Move into below
+ public QueryProfileType getType() { return type; }
+
+ /**
+ * Returns whether or not the given field name can be overridden at runtime.
+ * Attempts to override values which cannot be overridden will not fail but be ignored.
+ * Default: true.
+ *
+ * @param name the name of the field to check
+ * @param context the context in which to check, or null if none
+ */
+ public final boolean isOverridable(CompoundName name, Map<String, String> context) {
+ return unoverridables.get(name, context) == null;
+ }
+
+ /** Returns the type of a given prefix reachable from this profile, or null if none */
+ public final QueryProfileType getType(CompoundName name, Map<String, String> context) {
+ return types.get(name, context);
+ }
+
+ /** Returns the types reachable from this, or an empty map (never null) if none */
+ public DimensionalMap<CompoundName, QueryProfileType> getTypes() { return types; }
+
+ /** Returns the references reachable from this, or an empty map (never null) if none */
+ public DimensionalMap<CompoundName, Object> getReferences() { return references; }
+
+ /**
+ * Return all objects that start with the given prefix path using no context. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(final CompoundName prefix) { return listValues(prefix, Collections.<String,String>emptyMap()); }
+ public final Map<String, Object> listValues(final String prefix) { return listValues(new CompoundName(prefix)); }
+ /**
+ * Return all objects that start with the given prefix path. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(final String prefix,Map<String,String> context) {
+ return listValues(new CompoundName(prefix), context);
+ }
+ /**
+ * Return all objects that start with the given prefix path. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public final Map<String, Object> listValues(final CompoundName prefix,Map<String,String> context) {
+ return listValues(prefix, context, null);
+ }
+ /**
+ * Adds all objects that start with the given path prefix to the given value map. Use "" to list all.
+ * <p>
+ * For example, if {a.d =&gt; "a.d-value" ,a.e =&gt; "a.e-value", b.d =&gt; "b.d-value", then calling listValues("a")
+ * will return {"d" =&gt; "a.d-value","e" =&gt; "a.e-value"}
+ */
+ public Map<String, Object> listValues(CompoundName prefix, Map<String,String> context, Properties substitution) {
+ Map<String, Object> values = new HashMap<>();
+ for (Map.Entry<CompoundName, DimensionalValue<Object>> entry : entries.entrySet()) {
+ if ( entry.getKey().size() <= prefix.size()) continue;
+ if ( ! entry.getKey().hasPrefix(prefix)) continue;
+
+ Object value = entry.getValue().get(context);
+ if (value == null) continue;
+
+ value = substitute(value, context, substitution);
+ CompoundName suffixName = entry.getKey().rest(prefix.size());
+ values.put(suffixName.toString(), value);
+ }
+ return values;
+ }
+
+ public final Object get(String name) {
+ return get(name, Collections.<String,String>emptyMap());
+ }
+ public final Object get(String name, Map<String,String> context) {
+ return get(name, context, new QueryProfileProperties(this));
+ }
+ public final Object get(String name, Map<String,String> context, Properties substitution) {
+ return get(new CompoundName(name), context, substitution);
+ }
+ public final Object get(CompoundName name, Map<String, String> context, Properties substitution) {
+ return substitute(entries.get(name, context), context, substitution);
+ }
+
+ private Object substitute(Object value, Map<String,String> context, Properties substitution) {
+ if (value == null) return value;
+ if (substitution == null) return value;
+ if (value.getClass() != SubstituteString.class) return value;
+ return ((SubstituteString)value).substitute(context, substitution);
+ }
+
+ /** Throws IllegalArgumentException if the given string is not a valid query profile name */
+ private static void validateName(String name) {
+ Matcher nameMatcher=namePattern.matcher(name);
+ if ( ! nameMatcher.matches())
+ throw new IllegalArgumentException("Illegal name '" + name + "'");
+ }
+
+ @Override
+ public CompiledQueryProfile clone() {
+ return this; // immutable
+ }
+
+ @Override
+ public String toString() {
+ return "query profile '" + getId() + "'" + (type!=null ? " of type '" + type.getId() + "'" : "");
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java
new file mode 100644
index 00000000000..91a81888267
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java
@@ -0,0 +1,76 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.compiled;
+
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry;
+
+/**
+ * A set of compiled query profiles.
+ *
+ * @author bratseth
+ */
+public class CompiledQueryProfileRegistry extends ComponentRegistry<CompiledQueryProfile> {
+
+ private final QueryProfileTypeRegistry typeRegistry;
+
+ /** Creates a compiled query profile registry with no types */
+ public CompiledQueryProfileRegistry() {
+ this(QueryProfileTypeRegistry.emptyFrozen());
+ }
+
+ public CompiledQueryProfileRegistry(QueryProfileTypeRegistry typeRegistry) {
+ this.typeRegistry = typeRegistry;
+ }
+
+ /** Registers a type by its id */
+ public void register(CompiledQueryProfile profile) {
+ super.register(profile.getId(), profile);
+ }
+
+ public QueryProfileTypeRegistry getTypeRegistry() { return typeRegistry; }
+
+ /**
+ * <p>Returns a query profile for the given request string, or null if a suitable one is not found.</p>
+ *
+ * The request string must be a valid {@link com.yahoo.component.ComponentId} or null.<br>
+ * If the string is null, the profile named "default" is returned, or null if that does not exists.
+ *
+ * <p>
+ * The version part (if any) is matched used the usual component version patching rules.
+ * If the name part matches a query profile name perfectly, that profile is returned.
+ * If not, and the name is a slash-separated path, the profile with the longest matching left sub-path
+ * which has a type which allows path matching is used. If there is no such profile, null is returned.
+ */
+ public CompiledQueryProfile findQueryProfile(String idString) {
+ if (idString==null || idString.isEmpty()) return getComponent("default");
+ ComponentSpecification id=new ComponentSpecification(idString);
+ CompiledQueryProfile profile=getComponent(id);
+ if (profile!=null) return profile;
+
+ return findPathParentQueryProfile(new ComponentSpecification(idString));
+ }
+
+ private CompiledQueryProfile findPathParentQueryProfile(ComponentSpecification id) {
+ // Try the name with "/" appended - should have the same semantics with path matching
+ CompiledQueryProfile slashedProfile=getComponent(new ComponentSpecification(id.getName() + "/",id.getVersionSpecification()));
+ if (slashedProfile!=null && slashedProfile.getType()!=null && slashedProfile.getType().getMatchAsPath())
+ return slashedProfile;
+
+ // Extract the parent (if any)
+ int slashIndex=id.getName().lastIndexOf("/");
+ if (slashIndex<1) return null;
+ String parentName=id.getName().substring(0,slashIndex);
+ if (parentName.equals("")) return null;
+
+ ComponentSpecification parentId=new ComponentSpecification(parentName,id.getVersionSpecification());
+
+ CompiledQueryProfile pathParentProfile=getComponent(parentId);
+
+ if (pathParentProfile!=null && pathParentProfile.getType()!=null && pathParentProfile.getType().getMatchAsPath())
+ return pathParentProfile;
+ return findPathParentQueryProfile(parentId);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java
new file mode 100644
index 00000000000..b82939fa4ac
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java
@@ -0,0 +1,68 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.compiled;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.search.query.profile.DimensionBinding;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A map which may return different values depending on the values given in a context
+ * supplied with the key on all operations.
+ * <p>
+ * Dimensional maps are immutable and created through a DimensionalMap.Builder
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DimensionalMap<KEY, VALUE> {
+
+ private final Map<KEY, DimensionalValue<VALUE>> values;
+
+ private DimensionalMap(Map<KEY, DimensionalValue<VALUE>> values) {
+ this.values = ImmutableMap.copyOf(values);
+ }
+
+ /** Returns the value for this key matching a context, or null if none */
+ public VALUE get(KEY key, Map<String, String> context) {
+ DimensionalValue<VALUE> variants = values.get(key);
+ if (variants == null) return null;
+ return variants.get(context);
+ }
+
+ /** Returns the set of dimensional entries across all contexts. */
+ public Set<Map.Entry<KEY, DimensionalValue<VALUE>>> entrySet() {
+ return values.entrySet();
+ }
+
+ /** Returns true if this is empty for all contexts. */
+ public boolean isEmpty() {
+ return values.isEmpty();
+ }
+
+ public static class Builder<KEY, VALUE> {
+
+ private Map<KEY, DimensionalValue.Builder<VALUE>> entries = new HashMap<>();
+
+ // TODO: DimensionBinding -> Binding?
+ public void put(KEY key, DimensionBinding binding, VALUE value) {
+ DimensionalValue.Builder<VALUE> entry = entries.get(key);
+ if (entry == null) {
+ entry = new DimensionalValue.Builder<>();
+ entries.put(key, entry);
+ }
+ entry.add(value, binding);
+ }
+
+ public DimensionalMap<KEY, VALUE> build() {
+ Map<KEY, DimensionalValue<VALUE>> map = new HashMap<>();
+ for (Map.Entry<KEY, DimensionalValue.Builder<VALUE>> entry : entries.entrySet()) {
+ map.put(entry.getKey(), entry.getValue().build());
+ }
+ return new DimensionalMap<>(map);
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java
new file mode 100644
index 00000000000..0112928ada6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java
@@ -0,0 +1,159 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.compiled;
+
+import com.yahoo.search.query.profile.DimensionBinding;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Contains the values a given key in a DimensionalMap may take for different dimensional contexts.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DimensionalValue<VALUE> {
+
+ private final List<Value<VALUE>> values;
+
+ /** Create a set of variants which is a single value regardless of dimensions */
+ public DimensionalValue(Value<VALUE> value) {
+ this.values = Collections.singletonList(value);
+ }
+
+ public DimensionalValue(List<Value<VALUE>> valueVariants) {
+ if (valueVariants.size() == 1) { // special cased for efficiency
+ this.values = Collections.singletonList(valueVariants.get(0));
+ }
+ else {
+ this.values = new ArrayList<>(valueVariants);
+ Collections.sort(this.values);
+ }
+ }
+
+ /** Returns the value matching this context, or null if none */
+ public VALUE get(Map<String, String> context) {
+ if (context == null)
+ context = Collections.emptyMap();
+ for (Value<VALUE> value : values) {
+ if (value.matches(context))
+ return value.value();
+ }
+ return null;
+ }
+
+ public boolean isEmpty() { return values.isEmpty(); }
+
+ @Override
+ public String toString() {
+ return values.toString();
+ }
+
+ public static class Builder<VALUE> {
+
+ /** The minimal set of variants needed to capture all values at this key */
+ private Map<VALUE, Value.Builder<VALUE>> buildableVariants = new HashMap<>();
+
+ public void add(VALUE value, DimensionBinding variantBinding) {
+ // Note: We know we can index by the value because its possible types are constrained
+ // to what query profiles allow: String, primitives and query profiles
+ Value.Builder variant = buildableVariants.get(value);
+ if (variant == null) {
+ variant = new Value.Builder<>(value);
+ buildableVariants.put(value, variant);
+ }
+ variant.addVariant(variantBinding);
+ }
+
+ public DimensionalValue<VALUE> build() {
+ List<Value> variants = new ArrayList<>();
+ for (Value.Builder buildableVariant : buildableVariants.values()) {
+ variants.addAll(buildableVariant.build());
+ }
+ return new DimensionalValue(variants);
+ }
+
+ }
+
+ /** A value for a particular binding */
+ private static class Value<VALUE> implements Comparable<Value> {
+
+ private VALUE value = null;
+
+ /** The minimal binding this holds for */
+ private Binding binding = null;
+
+ public Value(VALUE value, Binding binding) {
+ this.value = value;
+ this.binding = binding;
+ }
+
+ /** Returns the value at this entry or null if none */
+ public VALUE value() { return value; }
+
+ /** Returns the binding that must match for this to be a valid entry, or Binding.nullBinding if none */
+ public Binding binding() {
+ if (binding == null) return Binding.nullBinding;
+ return binding;
+ }
+
+ public boolean matches(Map<String, String> context) {
+ return binding.matches(context);
+ }
+
+ @Override
+ public int compareTo(Value other) {
+ return this.binding.compareTo(other.binding);
+ }
+
+ @Override
+ public String toString() {
+ return " value '" + value + "' for " + binding;
+ }
+
+ /**
+ * A single value with the minimal set of dimension combinations it holds for.
+ */
+ private static class Builder<VALUE> {
+
+ private final VALUE value;
+
+ /**
+ * The set of bindings this value is for.
+ * Some of these are more general versions of others.
+ * We need to keep both to allow interleaving a different value with medium generality.
+ */
+ private Set<DimensionBinding> variants = new HashSet<>();
+
+ public Builder(VALUE value) {
+ this.value = value;
+ }
+
+ /** Add a binding this holds for */
+ public void addVariant(DimensionBinding binding) {
+ variants.add(binding);
+ }
+
+ /** Build a separate value object for each dimension combination which has this value */
+ public List<Value<VALUE>> build() {
+ // Shortcut for efficiency of the normal case
+ if (variants.size()==1)
+ return Collections.singletonList(new Value<>(value, Binding.createFrom(variants.iterator().next())));
+
+ List<Value<VALUE>> values = new ArrayList<>(variants.size());
+ for (DimensionBinding variant : variants)
+ values.add(new Value<>(value, Binding.createFrom(variant)));
+ return values;
+ }
+
+ public Object value() {
+ return value;
+ }
+
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java
new file mode 100644
index 00000000000..5770665e3a1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java
@@ -0,0 +1,227 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.config;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.config.subscription.ConfigSubscriber;
+import com.yahoo.search.query.profile.DimensionValues;
+import com.yahoo.search.query.profile.QueryProfile;
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.FieldType;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry;
+import com.yahoo.text.BooleanParser;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * @author bratseth
+ */
+public class QueryProfileConfigurer implements ConfigSubscriber.SingleSubscriber<QueryProfilesConfig> {
+
+ private final ConfigSubscriber subscriber = new ConfigSubscriber();
+
+ private volatile QueryProfileRegistry currentRegistry;
+
+ public QueryProfileConfigurer(String configId) {
+ subscriber.subscribe(this, QueryProfilesConfig.class, configId);
+ }
+
+ /** Returns the registry created by the last occurring call to configure */
+ public QueryProfileRegistry getCurrentRegistry() { return currentRegistry; }
+
+ private void setCurrentRegistry(QueryProfileRegistry registry) {
+ this.currentRegistry=registry;
+ }
+
+ public void configure(QueryProfilesConfig config) {
+ QueryProfileRegistry registry = createFromConfig(config);
+ setCurrentRegistry(registry);
+ }
+
+ public static QueryProfileRegistry createFromConfig(QueryProfilesConfig config) {
+ QueryProfileRegistry registry=new QueryProfileRegistry();
+
+ // Pass 1: Create all profiles and profile types
+ for (QueryProfilesConfig.Queryprofiletype profileTypeConfig : config.queryprofiletype()) {
+ createProfileType(profileTypeConfig,registry.getTypeRegistry());
+ }
+ for (QueryProfilesConfig.Queryprofile profileConfig : config.queryprofile()) {
+ createProfile(profileConfig,registry);
+ }
+
+ // Pass 2: Resolve references and add content
+ for (QueryProfilesConfig.Queryprofiletype profileTypeConfig : config.queryprofiletype()) {
+ fillProfileType(profileTypeConfig,registry.getTypeRegistry());
+ }
+
+ // To ensure topological sorting, using DPS. This will _NOT_ detect cycles (but it will not fail if they
+ // exist either)
+ Set<ComponentId> filled = new HashSet<>();
+ for (QueryProfilesConfig.Queryprofile profileConfig : config.queryprofile()) {
+ fillProfile(profileConfig, config, registry, filled);
+ }
+
+ registry.freeze();
+ return registry;
+ }
+
+ /** Stop subscribing from this configurer */
+ public void shutdown() {
+ subscriber.close();
+ }
+
+ private static void createProfile(QueryProfilesConfig.Queryprofile config,QueryProfileRegistry registry) {
+ QueryProfile profile=new QueryProfile(config.id());
+ try {
+ String typeId=config.type();
+ if (typeId!=null && !typeId.isEmpty())
+ profile.setType(registry.getType(typeId));
+
+ if (config.dimensions().size()>0) {
+ String[] dimensions=new String[config.dimensions().size()];
+ for (int i=0; i<config.dimensions().size(); i++)
+ dimensions[i]=config.dimensions().get(i);
+ profile.setDimensions(dimensions);
+ }
+
+ registry.register(profile);
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid " + profile,e);
+ }
+ }
+
+ private static void createProfileType(QueryProfilesConfig.Queryprofiletype config, QueryProfileTypeRegistry registry) {
+ QueryProfileType type=new QueryProfileType(config.id());
+ type.setStrict(config.strict());
+ type.setMatchAsPath(config.matchaspath());
+ registry.register(type);
+ }
+
+ private static void fillProfile(QueryProfilesConfig.Queryprofile config,
+ QueryProfilesConfig queryProfilesConfig,
+ QueryProfileRegistry registry,
+ Set<ComponentId> filled) {
+ QueryProfile profile=registry.getComponent(new ComponentSpecification(config.id()).toId());
+ if (filled.contains(profile.getId())) return;
+ filled.add(profile.getId());
+ try {
+ for (String inheritedId : config.inherit()) {
+ QueryProfile inherited=registry.getComponent(inheritedId);
+ if (inherited==null)
+ throw new IllegalArgumentException("Inherited query profile '" + inheritedId + "' in " + profile + " was not found");
+ fillProfile(inherited, queryProfilesConfig, registry, filled);
+ profile.addInherited(inherited);
+ }
+
+ for (QueryProfilesConfig.Queryprofile.Reference referenceConfig : config.reference()) {
+ QueryProfile referenced=registry.getComponent(referenceConfig.value());
+ if (referenced==null)
+ throw new IllegalArgumentException("Query profile '" + referenceConfig.value() + "' referenced as '" +
+ referenceConfig.name() + "' in " + profile + " was not found");
+ profile.set(referenceConfig.name(),referenced, registry);
+ if (referenceConfig.overridable()!=null && !referenceConfig.overridable().isEmpty())
+ profile.setOverridable(referenceConfig.name(),BooleanParser.parseBoolean(referenceConfig.overridable()),null);
+ }
+
+ for (QueryProfilesConfig.Queryprofile.Property propertyConfig : config.property()) {
+ profile.set(propertyConfig.name(),propertyConfig.value(), registry);
+ if (propertyConfig.overridable()!=null && !propertyConfig.overridable().isEmpty())
+ profile.setOverridable(propertyConfig.name(),BooleanParser.parseBoolean(propertyConfig.overridable()),null);
+ }
+
+ for (QueryProfilesConfig.Queryprofile.Queryprofilevariant variantConfig : config.queryprofilevariant()) {
+ String[] forDimensionValueArray=new String[variantConfig.fordimensionvalues().size()];
+ for (int i=0; i<variantConfig.fordimensionvalues().size(); i++) {
+ forDimensionValueArray[i]=variantConfig.fordimensionvalues().get(i).trim();
+ if ("*".equals(forDimensionValueArray[i]))
+ forDimensionValueArray[i]=null;
+ }
+ DimensionValues forDimensionValues=DimensionValues.createFrom(forDimensionValueArray);
+
+ for (String inheritedId : variantConfig.inherit()) {
+ QueryProfile inherited=registry.getComponent(inheritedId);
+ if (inherited==null)
+ throw new IllegalArgumentException("Inherited query profile '" + inheritedId + "' in " + profile +
+ " for '" + forDimensionValues + "' was not found");
+ fillProfile(inherited, queryProfilesConfig, registry, filled);
+ profile.addInherited(inherited, forDimensionValues);
+ }
+
+ for (QueryProfilesConfig.Queryprofile.Queryprofilevariant.Reference referenceConfig : variantConfig.reference()) {
+ QueryProfile referenced=registry.getComponent(referenceConfig.value());
+ if (referenced==null)
+ throw new IllegalArgumentException("Query profile '" + referenceConfig.value() + "' referenced as '" +
+ referenceConfig.name() + "' in " + profile + " for '" + forDimensionValues + "' was not found");
+ profile.set(referenceConfig.name(), referenced, forDimensionValues, registry);
+ }
+
+ for (QueryProfilesConfig.Queryprofile.Queryprofilevariant.Property propertyConfig : variantConfig.property()) {
+ profile.set(propertyConfig.name(), propertyConfig.value(), forDimensionValues, registry);
+ }
+
+ }
+
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid " + profile,e);
+ }
+ }
+
+ /** Fill a given profile by locating its config */
+ private static void fillProfile(QueryProfile inherited,
+ QueryProfilesConfig queryProfilesConfig,
+ QueryProfileRegistry registry,
+ Set<ComponentId> visited) {
+ for (QueryProfilesConfig.Queryprofile inheritedConfig : queryProfilesConfig.queryprofile()) {
+ if (inherited.getId().stringValue().equals(inheritedConfig.id())) {
+ fillProfile(inheritedConfig, queryProfilesConfig, registry, visited);
+ }
+ }
+ }
+
+ private static void fillProfileType(QueryProfilesConfig.Queryprofiletype config,QueryProfileTypeRegistry registry) {
+ QueryProfileType type=registry.getComponent(new ComponentSpecification(config.id()).toId());
+ try {
+
+ for (String inheritedId : config.inherit()) {
+ QueryProfileType inherited=registry.getComponent(inheritedId);
+ if (inherited==null)
+ throw new IllegalArgumentException("Inherited query profile type '" + inheritedId + "' in " + type + " was not found");
+ else
+ type.inherited().add(inherited);
+
+ }
+
+ for (QueryProfilesConfig.Queryprofiletype.Field fieldConfig : config.field())
+ instantiateFieldDescription(fieldConfig,type,registry);
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid " + type,e);
+ }
+ }
+
+ private static void instantiateFieldDescription(QueryProfilesConfig.Queryprofiletype.Field fieldConfig,
+ QueryProfileType type,
+ QueryProfileTypeRegistry registry) {
+ try {
+ FieldType fieldType=FieldType.fromString(fieldConfig.type(),registry);
+ FieldDescription field=new FieldDescription(
+ fieldConfig.name(),
+ fieldType,
+ fieldConfig.alias(),
+ fieldConfig.mandatory(),
+ fieldConfig.overridable()
+ );
+ type.addField(field, registry);
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid field '" + fieldConfig.name() + "' in " + type,e);
+ }
+ }
+
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java
new file mode 100644
index 00000000000..97e3fb90dc9
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java
@@ -0,0 +1,366 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.config;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.io.reader.NamedReader;
+import com.yahoo.search.query.profile.DimensionValues;
+import com.yahoo.search.query.profile.QueryProfile;
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.FieldType;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry;
+import com.yahoo.text.XML;
+import org.w3c.dom.Element;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Logger;
+
+/**
+ * A class which imports query profiles and types from XML files
+ *
+ * @author bratseth
+ */
+public class QueryProfileXMLReader {
+
+ private static Logger logger=Logger.getLogger(QueryProfileXMLReader.class.getName());
+
+ /**
+ * Reads all query profile xml files in a given directory,
+ * and all type xml files from the immediate subdirectory "types/" (if any)
+ *
+ * @throws RuntimeException if <code>directory</code> is not a readable directory, or if there is some error in the XML
+ */
+ public QueryProfileRegistry read(String directory) {
+ List<NamedReader> queryProfileReaders=new ArrayList<>();
+ List<NamedReader> queryProfileTypeReaders=new ArrayList<>();
+ try {
+ File dir=new File(directory);
+ if ( !dir.isDirectory() ) throw new IllegalArgumentException("Could not read query profiles: '" +
+ directory + "' is not a valid directory.");
+
+ for (File file : sortFiles(dir)) {
+ if ( ! file.getName().endsWith(".xml")) continue;
+ queryProfileReaders.add(new NamedReader(file.getName(),new FileReader(file)));
+ }
+ File typeDir=new File(dir,"types");
+ if (typeDir.isDirectory()) {
+ for (File file : sortFiles(typeDir)) {
+ if ( ! file.getName().endsWith(".xml")) continue;
+ queryProfileTypeReaders.add(new NamedReader(file.getName(),new FileReader(file)));
+ }
+ }
+
+ return read(queryProfileTypeReaders,queryProfileReaders);
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Could not read query profiles from '" + directory + "'",e);
+ }
+ finally {
+ closeAll(queryProfileReaders);
+ closeAll(queryProfileTypeReaders);
+ }
+ }
+
+ private List<File> sortFiles(File dir) {
+ ArrayList<File> files = new ArrayList<>();
+ files.addAll(Arrays.asList(dir.listFiles()));
+ Collections.sort(files);
+ return files;
+ }
+
+ private void closeAll(List<NamedReader> readers) {
+ for (NamedReader reader : readers) {
+ try { reader.close(); } catch (IOException e) { }
+ }
+ }
+
+ /**
+ * Read the XML file readers into a registry. This does not close the readers.
+ * This method is used directly from the admin system.
+ */
+ public QueryProfileRegistry read(List<NamedReader> queryProfileTypeReaders,List<NamedReader> queryProfileReaders) {
+ QueryProfileRegistry registry=new QueryProfileRegistry();
+
+ // Phase 1
+ List<Element> queryProfileTypeElements=createQueryProfileTypes(queryProfileTypeReaders,registry.getTypeRegistry());
+ List<Element> queryProfileElements=createQueryProfiles(queryProfileReaders,registry);
+
+ // Phase 2
+ fillQueryProfileTypes(queryProfileTypeElements,registry.getTypeRegistry());
+ fillQueryProfiles(queryProfileElements,registry);
+ return registry;
+ }
+
+ public List<Element> createQueryProfileTypes(List<NamedReader> queryProfileTypeReaders, QueryProfileTypeRegistry registry) {
+ List<Element> queryProfileTypeElements=new ArrayList<>(queryProfileTypeReaders.size());
+ for (NamedReader reader : queryProfileTypeReaders) {
+ Element root=XML.getDocument(reader).getDocumentElement();
+ if ( ! root.getNodeName().equals("query-profile-type")) {
+ logger.info("Ignoring '" + reader.getName() +
+ "': Expected XML root element 'query-profile-type' but was '" + root.getNodeName() + "'");
+ continue;
+ }
+
+ String idString=root.getAttribute("id");
+ if (idString==null || idString.equals(""))
+ throw new IllegalArgumentException("'" + reader.getName() + "' has no 'id' attribute in the root element");
+ ComponentId id=new ComponentId(idString);
+ validateFileNameToId(reader.getName(),id,"query profile type");
+ QueryProfileType type=new QueryProfileType(id);
+ type.setMatchAsPath(XML.getChild(root,"match") != null);
+ type.setStrict(XML.getChild(root,"strict") != null);
+ registry.register(type);
+ queryProfileTypeElements.add(root);
+ }
+ return queryProfileTypeElements;
+ }
+
+ public List<Element> createQueryProfiles(List<NamedReader> queryProfileReaders, QueryProfileRegistry registry) {
+ List<Element> queryProfileElements=new ArrayList<>(queryProfileReaders.size());
+ for (NamedReader reader : queryProfileReaders) {
+ Element root=XML.getDocument(reader).getDocumentElement();
+ if ( ! root.getNodeName().equals("query-profile")) {
+ logger.info("Ignoring '" + reader.getName() +
+ "': Expected XML root element 'query-profile' but was '" + root.getNodeName() + "'");
+ continue;
+ }
+
+ String idString=root.getAttribute("id");
+ if (idString==null || idString.equals(""))
+ throw new IllegalArgumentException("Query profile '" + reader.getName() + "' has no 'id' attribute in the root element");
+ ComponentId id=new ComponentId(idString);
+ validateFileNameToId(reader.getName(),id,"query profile");
+
+ QueryProfile queryProfile=new QueryProfile(id);
+ String typeId=root.getAttribute("type");
+ if (typeId!=null && ! typeId.equals("")) {
+ QueryProfileType type=registry.getType(typeId);
+ if (type==null)
+ throw new IllegalArgumentException("Query profile '" + reader.getName() + "': Type id '" + typeId + "' can not be resolved");
+ queryProfile.setType(type);
+ }
+
+ Element dimensions=XML.getChild(root,"dimensions");
+ if (dimensions!=null)
+ queryProfile.setDimensions(toArray(XML.getValue(dimensions)));
+
+ registry.register(queryProfile);
+ queryProfileElements.add(root);
+ }
+ return queryProfileElements;
+ }
+
+ /** Throws an exception if the name is not corresponding to the id */
+ private void validateFileNameToId(final String actualName,ComponentId id,String artifactName) {
+ String expectedCanonicalFileName=id.toFileName();
+ String expectedAlternativeFileName=id.stringValue().replace(":","-").replace("/","_"); // legacy
+ String fileName=new File(actualName).getName();
+ fileName=stripXmlEnding(fileName);
+ String canonicalFileName=ComponentId.fromFileName(fileName).toFileName();
+ if ( ! canonicalFileName.equals(expectedCanonicalFileName) && ! canonicalFileName.equals(expectedAlternativeFileName))
+ throw new IllegalArgumentException("The file name of " + artifactName + " '" + id +
+ "' must be '" + expectedCanonicalFileName + ".xml' but was '" + actualName + "'");
+ }
+
+ private String stripXmlEnding(String fileName) {
+ if (!fileName.endsWith(".xml"))
+ throw new IllegalArgumentException("'" + fileName + "' should have a .xml ending");
+ else
+ return fileName.substring(0,fileName.length()-4);
+ }
+
+ private String[] toArray(String csv) {
+ String[] array=csv.split(",");
+ for (int i=0; i<array.length; i++)
+ array[i]=array[i].trim();
+ return array;
+ }
+
+ public void fillQueryProfileTypes(List<Element> queryProfileTypeElements, QueryProfileTypeRegistry registry) {
+ for (Element element : queryProfileTypeElements) {
+ QueryProfileType type=registry.getComponent(new ComponentSpecification(element.getAttribute("id")).toId());
+ try {
+ readInheritedTypes(element,type,registry);
+ readFieldDefinitions(element,type,registry);
+ }
+ catch (RuntimeException e) {
+ throw new IllegalArgumentException("Error reading " + type,e);
+ }
+ }
+ }
+
+ private void readInheritedTypes(Element element,QueryProfileType type,QueryProfileTypeRegistry registry) {
+ String inheritedString=element.getAttribute("inherits");
+ if (inheritedString==null || inheritedString.equals("")) return;
+ for (String inheritedId : inheritedString.split(" ")) {
+ inheritedId=inheritedId.trim();
+ if (inheritedId.equals("")) continue;
+ QueryProfileType inheritedType=registry.getComponent(inheritedId);
+ if (inheritedType==null) throw new IllegalArgumentException("Could not resolve inherited query profile type '" + inheritedId);
+ type.inherited().add(inheritedType);
+ }
+ }
+
+ private void readFieldDefinitions(Element element,QueryProfileType type,QueryProfileTypeRegistry registry) {
+ for (Element field : XML.getChildren(element,"field")) {
+ String name=field.getAttribute("name");
+ if (name==null || name.equals("")) throw new IllegalArgumentException("A field has no 'name' attribute");
+ try {
+ String fieldTypeName=field.getAttribute("type");
+ if (fieldTypeName==null) throw new IllegalArgumentException("Field '" + field + "' has no 'type' attribute");
+ FieldType fieldType=FieldType.fromString(fieldTypeName,registry);
+ type.addField(new FieldDescription(name,fieldType,field.getAttribute("alias"),
+ getBooleanAttribute("mandatory",false,field),getBooleanAttribute("overridable",true,field)), registry);
+ }
+ catch(RuntimeException e) {
+ throw new IllegalArgumentException("Invalid field '" + name + "'",e);
+ }
+ }
+ }
+
+ public void fillQueryProfiles(List<Element> queryProfileElements, QueryProfileRegistry registry) {
+ for (Element element : queryProfileElements) {
+ // Lookup by exact id
+ QueryProfile profile=registry.getComponent(new ComponentSpecification(element.getAttribute("id")).toId());
+ try {
+ readInherited(element,profile,registry,null,profile.toString());
+ readFields(element,profile,registry,null,profile.toString());
+ readVariants(element,profile,registry);
+ }
+ catch (RuntimeException e) {
+ throw new IllegalArgumentException("Error reading " + profile,e);
+ }
+ }
+ }
+
+ private void readInherited(Element element,QueryProfile profile,QueryProfileRegistry registry,DimensionValues dimensionValues,String sourceDescription) {
+ String inheritedString=element.getAttribute("inherits");
+ if (inheritedString==null || inheritedString.equals("")) return;
+ for (String inheritedId : inheritedString.split(" ")) {
+ inheritedId=inheritedId.trim();
+ if (inheritedId.equals("")) continue;
+ QueryProfile inheritedProfile=registry.getComponent(inheritedId);
+ if (inheritedProfile==null) throw new IllegalArgumentException("Could not resolve inherited query profile '" + inheritedId + "' in " + sourceDescription);
+ profile.addInherited(inheritedProfile,dimensionValues);
+ }
+ }
+
+ private void readFields(Element element,QueryProfile profile,QueryProfileRegistry registry,DimensionValues dimensionValues,String sourceDescription) {
+ List<KeyValue> references=new ArrayList<>();
+ List<KeyValue> properties=new ArrayList<>();
+ for (Element field : XML.getChildren(element,"field")) {
+ String name=field.getAttribute("name");
+ if (name==null || name.equals("")) throw new IllegalArgumentException("A field in " + sourceDescription + " has no 'name' attribute");
+ try {
+ Boolean overridable=getBooleanAttribute("overridable",null,field);
+ if (overridable!=null)
+ profile.setOverridable(name,overridable,null);
+
+ Object fieldValue=readFieldValue(field,name,sourceDescription,registry);
+ if (fieldValue instanceof QueryProfile)
+ references.add(new KeyValue(name,fieldValue));
+ else
+ properties.add(new KeyValue(name,fieldValue));
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid field '" + name + "' in " + sourceDescription,e);
+ }
+ }
+ // Must set references before properties
+ for (KeyValue keyValue : references)
+ profile.set(keyValue.getKey() ,keyValue.getValue(), dimensionValues, registry);
+ for (KeyValue keyValue : properties)
+ profile.set(keyValue.getKey(), keyValue.getValue(), dimensionValues, registry);
+
+ }
+
+ private Object readFieldValue(Element field,String name,String targetDescription,QueryProfileRegistry registry) {
+ Element ref=XML.getChild(field,"ref");
+ if (ref!=null) {
+ String referencedName=XML.getValue(ref);
+ QueryProfile referenced=registry.getComponent(referencedName);
+ if (referenced==null)
+ throw new IllegalArgumentException("Could not find query profile '" + referencedName + "' referenced as '" +
+ name + "' in " + targetDescription);
+ return referenced;
+ }
+ else {
+ return XML.getValue(field);
+ }
+ }
+
+ private void readVariants(Element element,QueryProfile profile,QueryProfileRegistry registry) {
+ for (Element queryProfileVariantElement : XML.getChildren(element,"query-profile")) { // A "virtual" query profile contained inside another
+ List<String> dimensions=profile.getDimensions();
+ if (dimensions==null)
+ throw new IllegalArgumentException("Cannot create a query profile variant in " + profile +
+ ", as it has not declared any variable dimensions");
+ String dimensionString=queryProfileVariantElement.getAttribute("for");
+ String[] dimensionValueArray=makeStarsNull(toArray(dimensionString));
+ if (dimensions.size()<dimensionValueArray.length)
+ throw new IllegalArgumentException("Cannot create a query profile variant for '" + dimensionString +
+ "' as only " + dimensions.size() + " dimensions has been defined");
+ DimensionValues dimensionValues=DimensionValues.createFrom(dimensionValueArray);
+
+ String description="variant '" + dimensionString + "' in " + profile.toString();
+ readInherited(queryProfileVariantElement,profile,registry,dimensionValues,description);
+ readFields(queryProfileVariantElement,profile,registry,dimensionValues,description);
+ }
+ }
+
+ private String[] makeStarsNull(String[] strings) {
+ for (int i=0; i<strings.length; i++)
+ if (strings[i].equals("*"))
+ strings[i]=null;
+ return strings;
+ }
+
+ /**
+ * Returns true if the string is "true".<br>
+ * Returns false if the string is "false".<br>
+ * Returns <code>default</code> if the string is null or empty (this parameter may be null)<br>
+ * @throws IllegalArgumentException if the string has any other value
+ */
+ private Boolean asBoolean(String s,Boolean defaultValue) {
+ if (s==null) return defaultValue;
+ if (s.isEmpty()) return defaultValue;
+ if ("true".equals(s)) return true;
+ if ("false".equals(s)) return false;
+ throw new IllegalArgumentException("Expected 'true' or 'false' but was'" + s + "'");
+ }
+
+ /** Returns the given attribute as a boolean, using the semantics of {@link #asBoolean} */
+ private Boolean getBooleanAttribute(String attributeName,Boolean defaultValue,Element from) {
+ try {
+ return asBoolean(from.getAttribute(attributeName),defaultValue);
+ }
+ catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Attribute '" + attributeName,e);
+ }
+ }
+
+ private static class KeyValue {
+
+ private String key;
+ private Object value;
+
+ public KeyValue(String key,Object value) {
+ this.key=key;
+ this.value=value;
+ }
+
+ public String getKey() { return key; }
+
+ public Object getValue() { return value; }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java b/container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java
new file mode 100644
index 00000000000..8ea4e887661
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.search.query.profile.config;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/package-info.java b/container-search/src/main/java/com/yahoo/search/query/profile/package-info.java
new file mode 100644
index 00000000000..df3f4ac45ab
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/package-info.java
@@ -0,0 +1,12 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Query Profiles provide nested sets of named (and optionally typed) key-values which can be referenced in a Query
+ * to proviode initial values of Query properties. Values in nested query profiles can be looked up from
+ * the query properties by dotting the names. Query profiles supports inheritance to allow variations
+ * for, e.g different buckets, client types, markets etc. */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.profile;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java
new file mode 100644
index 00000000000..c522ec04023
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java
@@ -0,0 +1,148 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.QueryProfile;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A field description of a query profile type. Immutable.
+ * Field descriptions can be sorted by name.
+ *
+ * @author bratseth
+ */
+public class FieldDescription implements Comparable<FieldDescription> {
+
+ private final CompoundName name;
+ private final FieldType type;
+ private final List<String> aliases;
+
+ /** If true, this value must be provided either in the query profile or in the search request */
+ private final boolean mandatory;
+
+ /** If true, assignments to this value from outside will be ignored */
+ private final boolean overridable;
+
+ public FieldDescription(String name, FieldType type) {
+ this(name,type,false);
+ }
+
+ public FieldDescription(String name, String type) {
+ this(name,FieldType.fromString(type,null));
+ }
+
+ public FieldDescription(String name, FieldType type, boolean mandatory) {
+ this(name, type, mandatory, true);
+ }
+
+ public FieldDescription(String name, String type, String aliases) {
+ this(name,type,aliases,false,true);
+ }
+
+ public FieldDescription(String name, FieldType type, String aliases) {
+ this(name, type, aliases, false, true);
+ }
+
+ /**
+ * Creates a field description
+ *
+ * @param name the name of the field
+ * @param typeString the type of the field represented as a string - see {@link com.yahoo.search.query.profile.types.FieldType}
+ * @param aliases a space-separated list of alias names of this field name. Aliases are not following dotted
+ * (meaning they are global, not that they cannot contain dots) and are case insensitive. Null is permissible
+ * if there are no aliases
+ * @param mandatory whether it is mandatory to provide a value for this field. default: false
+ * @param overridable whether this can be overridden when first set in a profile. Default: true
+ */
+ public FieldDescription(String name, String typeString, String aliases, boolean mandatory, boolean overridable) {
+ this(name,FieldType.fromString(typeString,null),aliases,mandatory,overridable);
+ }
+
+ public FieldDescription(String name, FieldType type, boolean mandatory, boolean overridable) {
+ this(name, type, null, mandatory, overridable);
+ }
+
+ public FieldDescription(String name, FieldType type, String aliases, boolean mandatory, boolean overridable) {
+ this(new CompoundName(name), type, aliases, mandatory, overridable);
+ }
+
+ /**
+ * Creates a field description from a list where the aliases are represented as a comma-separated string
+ */
+ public FieldDescription(CompoundName name, FieldType type, String aliases, boolean mandatory, boolean overridable) {
+ this(name, type, toList(aliases), mandatory, overridable);
+ }
+
+ /**
+ * Creates a field description
+ *
+ * @param name the name of the field
+ * @param type the type of the field represented as a string - see {@link com.yahoo.search.query.profile.types.FieldType}
+ * @param aliases a list of aliases, never null. Aliases are not following dotted
+ * (meaning they are global, not that they cannot contain dots) and are case insensitive.
+ * @param mandatory whether it is mandatory to provide a value for this field. default: false
+ * @param overridable whether this can be overridden when first set in a profile. Default: true
+ */
+ public FieldDescription(CompoundName name, FieldType type, List<String> aliases, boolean mandatory, boolean overridable) {
+ if (name.isEmpty())
+ throw new IllegalArgumentException("Illegal name ''");
+ for (String nameComponent : name.asList())
+ QueryProfile.validateName(nameComponent);
+ this.name = name;
+ this.type = type;
+
+ // Forbidden until we can figure out the right semantics
+ if (name.isCompound() && ! aliases.isEmpty()) throw new IllegalArgumentException("Aliases is not allowed with compound names");
+
+ this.aliases = ImmutableList.copyOf(aliases);
+ this.mandatory = mandatory;
+ this.overridable = overridable;
+ }
+
+ private static List<String> toList(String string) {
+ if (string == null || string.isEmpty()) return ImmutableList.of();
+ return ImmutableList.copyOf(Arrays.asList(string.split(" ")));
+ }
+
+ /** Returns the full name of this as a string */
+ public String getName() { return name.toString(); }
+
+ /** Returns the full name of this as a compound name */
+ public CompoundName getCompoundName() { return name; }
+
+ public FieldType getType() { return type; }
+
+ /** Returns a unmodifiable list of the aliases of this. An empty list (never null) if there are none. */
+ public List<String> getAliases() { return aliases; }
+
+ /** Returns whether this field must be provided in the query profile or the search definition. Default: false */
+ public boolean isMandatory() { return mandatory; }
+
+ /** Returns false if overrides to values for this field from the outside should be ignored. Default: true */
+ public boolean isOverridable() { return overridable; }
+
+ public int compareTo(FieldDescription other) {
+ return name.toString().compareTo(other.name.toString());
+ }
+
+ /** Returns a copy of this with the name set to the argument name */
+ public FieldDescription withName(CompoundName name) {
+ return new FieldDescription(name, type, aliases, mandatory, overridable);
+ }
+
+ /** Returns a copy of this with the type set to the argument type */
+ public FieldDescription withType(FieldType type) {
+ return new FieldDescription(name, type, aliases, mandatory, overridable);
+ }
+
+ @Override
+ public String toString() {
+ return "field '" + name + "' type " + type.stringValue() + "" +
+ (mandatory?" (mandatory)":"") + (!overridable?" (not overridable)":"");
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java
new file mode 100644
index 00000000000..abe3c4425ae
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java
@@ -0,0 +1,94 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.search.query.profile.QueryProfile;
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.yql.YqlQuery;
+import com.yahoo.tensor.Tensor;
+
+import java.util.Optional;
+
+/**
+ * Superclass of query type field types.
+ * Field types are immutable.
+ *
+ * @author bratseth
+ */
+@SuppressWarnings("rawtypes")
+public abstract class FieldType {
+
+ public static final PrimitiveFieldType stringType = new PrimitiveFieldType(String.class);
+ public static final PrimitiveFieldType integerType = new PrimitiveFieldType(Integer.class);
+ public static final PrimitiveFieldType longType = new PrimitiveFieldType(Long.class);
+ public static final PrimitiveFieldType floatType = new PrimitiveFieldType(Float.class);
+ public static final PrimitiveFieldType doubleType = new PrimitiveFieldType(Double.class);
+ public static final PrimitiveFieldType booleanType = new PrimitiveFieldType(Boolean.class);
+ public static final TensorFieldType genericTensorType = new TensorFieldType(Optional.empty());
+ public static final QueryFieldType queryType = new QueryFieldType();
+ public static final QueryProfileFieldType genericQueryProfileType = new QueryProfileFieldType();
+
+ /** Returns the class of instance values of this field type */
+ public abstract Class getValueClass();
+
+ /** Returns a string representation of this type which can be converted back to a type class by {@link #fromString} */
+ public abstract String stringValue();
+
+ public abstract String toString();
+
+ /** Returns a string describing possible instances of this type, suitable for user error messages */
+ public abstract String toInstanceDescription();
+
+ /** Converts the given type to an instance of this type, if possible. Returns null if not possible. */
+ public abstract Object convertFrom(Object o, QueryProfileRegistry registry);
+
+ /** Converts the given type to an instance of this type, if possible. Returns null if not possible. */
+ public abstract Object convertFrom(Object o, CompiledQueryProfileRegistry registry);
+
+ /**
+ * Returns the field type for a given string name.
+ *
+ * @param typeString a type string - a primitive name, "query-profile" or "query-profile:profile-name"
+ * @param registry the registry in which query profile references are resolved when the last form above is used,
+ * or null in which case that form cannot be used
+ * @throws IllegalArgumentException if the string does not resolve to a type
+ */
+ public static FieldType fromString(String typeString, QueryProfileTypeRegistry registry) {
+ if ("string".equals(typeString))
+ return stringType;
+ if ("integer".equals(typeString))
+ return integerType;
+ if ("long".equals(typeString))
+ return longType;
+ if ("float".equals(typeString))
+ return floatType;
+ if ("double".equals(typeString))
+ return doubleType;
+ if ("boolean".equals(typeString))
+ return booleanType;
+ if ("query".equals(typeString))
+ return queryType;
+ if (typeString.startsWith("tensor"))
+ return TensorFieldType.fromTypeString(typeString);
+ if ("query-profile".equals(typeString))
+ return genericQueryProfileType;
+ if (typeString.startsWith("query-profile:"))
+ return QueryProfileFieldType.fromString(typeString.substring("query-profile:".length()),registry);
+ throw new IllegalArgumentException("Unknown type '" + typeString + "'");
+ }
+
+ /** Returns the field type from a value class, or null if there is no type for it */
+ public static FieldType fromClass(Class clazz) {
+ if (clazz == String.class) return stringType;
+ if (clazz == Integer.class) return integerType;
+ if (clazz == Long.class) return longType;
+ if (clazz == Float.class) return floatType;
+ if (clazz == Double.class) return doubleType;
+ if (clazz == Boolean.class) return booleanType;
+ if (clazz == Tensor.class) return genericTensorType;
+ if (clazz == YqlQuery.class) return queryType;
+ if (clazz == QueryProfile.class) return genericQueryProfileType;
+ return null;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java
new file mode 100644
index 00000000000..76b3f78ac2f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java
@@ -0,0 +1,86 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+
+import static com.yahoo.text.Lowercase.toLowerCase;
+
+/**
+ * Represents a query field type which is a primitive - String, Integer, Float, Double or Long.
+ *
+ * @author bratseth
+ */
+@SuppressWarnings("rawtypes")
+public class PrimitiveFieldType extends FieldType {
+
+ private Class primitiveClass;
+
+ PrimitiveFieldType(Class primitiveClass) {
+ this.primitiveClass=primitiveClass;
+ }
+
+ public @Override Class getValueClass() { return primitiveClass; }
+
+ public @Override String stringValue() {
+ return toLowerCase(primitiveClass.getSimpleName());
+ }
+
+ public @Override String toString() { return "field type " + stringValue(); }
+
+ public @Override String toInstanceDescription() {
+ return toLowerCase(primitiveClass.getSimpleName());
+ }
+
+ @Override
+ public Object convertFrom(Object object, CompiledQueryProfileRegistry registry) {
+ return convertFrom(object, (QueryProfileRegistry)null);
+ }
+
+ public @Override Object convertFrom(Object object, QueryProfileRegistry registry) {
+ if (primitiveClass == object.getClass()) return object;
+
+ if (object.getClass() == String.class) return convertFromString((String)object);
+ if (object instanceof Number) return convertFromNumber((Number)object);
+
+ return null;
+ }
+
+ private Object convertFromString(String string) {
+ try {
+ if (primitiveClass==Integer.class) return Integer.valueOf(string);
+ if (primitiveClass==Double.class) return Double.valueOf(string);
+ if (primitiveClass==Float.class) return Float.valueOf(string);
+ if (primitiveClass==Long.class) return Long.valueOf(string);
+ if (primitiveClass==Boolean.class) return Boolean.valueOf(string);
+ }
+ catch (NumberFormatException e) {
+ return null; // Handled in caller
+ }
+ throw new RuntimeException("Programming error");
+ }
+
+ private Object convertFromNumber(Number number) {
+ if (primitiveClass==Integer.class) return number.intValue();
+ if (primitiveClass==Double.class) return number.doubleValue();
+ if (primitiveClass==Float.class) return number.floatValue();
+ if (primitiveClass==Long.class) return number.longValue();
+ if (primitiveClass==String.class) return String.valueOf(number);
+ throw new RuntimeException("Programming error: Input type is " + number.getClass() +
+ " primitiveClass is " + primitiveClass);
+ }
+
+ @Override
+ public int hashCode() {
+ return primitiveClass.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof PrimitiveFieldType)) return false;
+ PrimitiveFieldType other = (PrimitiveFieldType)o;
+ return other.primitiveClass.equals(this.primitiveClass);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java
new file mode 100644
index 00000000000..a0982fdf0f6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.yql.YqlQuery;
+import com.yahoo.tensor.MapTensor;
+import com.yahoo.tensor.Tensor;
+
+/**
+ * A YQL query template field type in a query profile
+ *
+ * @author bratseth
+ */
+public class QueryFieldType extends FieldType {
+
+ @Override
+ public Class getValueClass() { return YqlQuery.class; }
+
+ @Override
+ public String stringValue() { return "query"; }
+
+ @Override
+ public String toString() { return "field type " + stringValue(); }
+
+ @Override
+ public String toInstanceDescription() { return "a YQL query template"; }
+
+ @Override
+ public Object convertFrom(Object o, QueryProfileRegistry registry) {
+ if (o instanceof YqlQuery) return o;
+ if (o instanceof String) return YqlQuery.from((String)o);
+ return null;
+ }
+
+ @Override
+ public Object convertFrom(Object o, CompiledQueryProfileRegistry registry) {
+ return convertFrom(o, (QueryProfileRegistry)null);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java
new file mode 100644
index 00000000000..df52e78c6ef
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java
@@ -0,0 +1,100 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.search.query.profile.QueryProfile;
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfile;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+
+/**
+ * Represents a query profile field type which is a reference to a query profile.
+ * The reference may optionally specify the type of the referred query profile.
+ *
+ * @author bratseth
+ */
+public class QueryProfileFieldType extends FieldType {
+
+ private final QueryProfileType type;
+
+ public static QueryProfileFieldType fromString(String queryProfileName, QueryProfileTypeRegistry registry) {
+ if (queryProfileName==null || queryProfileName.equals(""))
+ return new QueryProfileFieldType(null);
+
+ if (registry==null)
+ throw new IllegalArgumentException("Can not resolve query profile type '" + queryProfileName +
+ "' because no registry is provided");
+ QueryProfileType queryProfileType=registry.getComponent(queryProfileName);
+ if (queryProfileType==null)
+ throw new IllegalArgumentException("Could not resolve query profile type '" + queryProfileName + "'");
+ return new QueryProfileFieldType(registry.getComponent(queryProfileName));
+ }
+
+ public QueryProfileFieldType() { this(null); }
+
+ public QueryProfileFieldType(QueryProfileType type) {
+ this.type = type;
+ }
+
+ /** Returns the query profile type of this, or null if any type works */
+ public QueryProfileType getQueryProfileType() { return type; }
+
+ public @Override Class<?> getValueClass() { return QueryProfile.class; }
+
+ public @Override String stringValue() {
+ return "query-profile" + (type!=null ? ":" + type.getId().getName() : "");
+ }
+
+ public @Override String toString() {
+ return "field type " + stringValue();
+ }
+
+ public @Override String toInstanceDescription() {
+ return "reference to a query profile" + (type!=null ? " of type '" + type.getId().getName() + "'" : "");
+ }
+
+ @Override
+ public CompiledQueryProfile convertFrom(Object object, CompiledQueryProfileRegistry registry) {
+ String profileId = object.toString();
+ if (profileId.startsWith("ref:"))
+ profileId = profileId.substring("ref:".length());
+ CompiledQueryProfile profile = registry.getComponent(profileId);
+ if (profile == null) return null;
+ if (type != null && ! type.equals(profile.getType())) return null;
+ return profile;
+ }
+
+ @Override
+ public QueryProfile convertFrom(Object object, QueryProfileRegistry registry) {
+ QueryProfile profile;
+ if (object instanceof String)
+ profile = registry.getComponent((String)object);
+ else if (object instanceof QueryProfile)
+ profile = (QueryProfile)object;
+ else
+ return null;
+
+ // Verify its type as well
+ if (type!=null && type!=profile.getType()) return null;
+ return profile;
+ }
+
+ @Override
+ public int hashCode() {
+ if (type == null) return 17;
+ return type.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof QueryProfileFieldType)) return false;
+ QueryProfileFieldType other = (QueryProfileFieldType)o;
+ return equals(this.type.getId(), other.type.getId());
+ }
+
+ private boolean equals(Object o1, Object o2) {
+ if (o1 == null) return o2 == null;
+ return o1.equals(o2);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java
new file mode 100644
index 00000000000..ecf60f8723d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java
@@ -0,0 +1,355 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.FreezableSimpleComponent;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.profile.QueryProfile;
+
+import java.util.*;
+
+import static com.yahoo.text.Lowercase.toLowerCase;
+
+/**
+ * Defines a kind of query profiles
+ *
+ * @author bratseth
+ */
+public class QueryProfileType extends FreezableSimpleComponent {
+
+ /** The fields of this query profile type */
+ private Map<String, FieldDescription> fields = new HashMap<>();
+
+ /** The query profile types this inherits */
+ private List<QueryProfileType> inherited = new ArrayList<>();
+
+ /** If this is true, keys which are not declared in this type cannot be set in instances */
+ private boolean strict = false;
+
+ /** True if the name of instances of this profile should be matched as path names, see QueryProfileRegistry */
+ private boolean matchAsPath = false;
+
+ private boolean builtin = false;
+
+ /** Aliases *from* any strings *to* field names. Aliases are case insensitive */
+ private Map<String, String> aliases = null;
+
+ public QueryProfileType(String idString) {
+ this(new ComponentId(idString));
+ }
+
+ public QueryProfileType(ComponentId id) {
+ super(id);
+ QueryProfile.validateName(id.getName());
+ }
+
+ private QueryProfileType(ComponentId id, Map<String, FieldDescription> fields, List<QueryProfileType> inherited,
+ boolean strict, boolean matchAsPath, boolean builtin, Map<String,String> aliases) {
+ super(id);
+ this.fields = new HashMap<>(fields);
+ this.inherited = new ArrayList<>(inherited);
+ this.strict = strict;
+ this.matchAsPath = matchAsPath;
+ this.builtin = builtin;
+ this.aliases = aliases == null ? null : new HashMap<>(aliases);
+ }
+
+ /** Return this is it is not frozen, returns a modifiable deeply unfrozen copy otherwise */
+ public QueryProfileType unfrozen() {
+ if ( ! isFrozen()) return this;
+
+ // Unfreeze inherited query profile references
+ List<QueryProfileType> unfrozenInherited = new ArrayList<>();
+ for (QueryProfileType inheritedType : inherited) {
+ unfrozenInherited.add(inheritedType.unfrozen());
+ }
+
+ // Unfreeze nested query profile references
+ Map<String, FieldDescription> unfrozenFields = new HashMap<>();
+ for (Map.Entry<String, FieldDescription> field : fields.entrySet()) {
+ FieldDescription unfrozenFieldValue = field.getValue();
+ if (field.getValue().getType() instanceof QueryProfileFieldType) {
+ QueryProfileFieldType queryProfileFieldType = (QueryProfileFieldType)field.getValue().getType();
+ if (queryProfileFieldType.getQueryProfileType() != null) {
+ QueryProfileFieldType unfrozenType =
+ new QueryProfileFieldType(queryProfileFieldType.getQueryProfileType().unfrozen());
+ unfrozenFieldValue = field.getValue().withType(unfrozenType);
+ }
+ }
+ unfrozenFields.put(field.getKey(), unfrozenFieldValue);
+ }
+
+ return new QueryProfileType(getId(), unfrozenFields, unfrozenInherited, strict, matchAsPath, builtin, aliases);
+ }
+
+ /** Mark this type as built into the system. Do not use */
+ public void setBuiltin(boolean builtin) { this.builtin=builtin; }
+
+ /** Returns whether this type is built into the system */
+ public boolean isBuiltin() { return builtin; }
+
+ /**
+ * Returns the query profile types inherited from this (never null).
+ * If this profile type is not frozen, this list can be modified to change the set of inherited types.
+ * If it is frozen, the returned list is immutable.
+ */
+ public List<QueryProfileType> inherited() { return inherited; }
+
+ /**
+ * Returns the fields declared in this (i.e not including those inherited) as an immutable map.
+ *
+ * @throws IllegalStateException if this is frozen
+ */
+ public Map<String,FieldDescription> declaredFields() {
+ ensureNotFrozen();
+ return Collections.unmodifiableMap(fields);
+ }
+
+ /**
+ * Returns true if <i>this</i> is declared strict.
+ * @throws IllegalStateException if this is frozen
+ */
+ public boolean isDeclaredStrict() {
+ ensureNotFrozen();
+ return strict;
+ }
+
+ /**
+ * Returns true if <i>this</i> is declared as match as path.
+ * @throws IllegalStateException if this is frozen
+ */
+ public boolean getDeclaredMatchAsPath() {
+ ensureNotFrozen();
+ return matchAsPath;
+ }
+
+ /** Set whether nondeclared fields are permissible. Throws an exception if this is frozen. */
+ public void setStrict(boolean strict) {
+ ensureNotFrozen();
+ this.strict=strict;
+ }
+
+ /** Returns whether field not declared in this type is permissible in instances. Default is false: Additional values are allowed */
+ public boolean isStrict() {
+ if (isFrozen()) return strict;
+
+ // Check if any of this or an inherited is true
+ if (strict) return true;
+ for (QueryProfileType inheritedType : inherited)
+ if (inheritedType.isStrict()) return true;
+ return false;
+ }
+
+ /** Returns whether instances of this should be matched as path names. Throws if this is frozen. */
+ public void setMatchAsPath(boolean matchAsPath) {
+ ensureNotFrozen();
+ this.matchAsPath=matchAsPath;
+ }
+
+ /** Returns whether instances of this should be matched as path names. Default is false: Use exact name matching. */
+ public boolean getMatchAsPath() {
+ if (isFrozen()) return matchAsPath;
+
+ // Check if any of this or an inherited is true
+ if (matchAsPath) return true;
+ for (QueryProfileType inheritedType : inherited)
+ if (inheritedType.getMatchAsPath()) return true;
+ return false;
+ }
+
+ public void freeze() {
+ if (isFrozen()) return;
+ // Flatten the inheritance hierarchy into this to facilitate faster lookup
+ for (QueryProfileType inheritedType : inherited) {
+ for (FieldDescription field : inheritedType.fields().values())
+ if ( ! fields.containsKey(field.getName()))
+ fields.put(field.getName(),field);
+ }
+ fields = ImmutableMap.copyOf(fields);
+ inherited = ImmutableList.copyOf(inherited);
+ strict = isStrict();
+ matchAsPath = getMatchAsPath();
+ super.freeze();
+ }
+
+ /**
+ * Returns whether the given field name is overridable in this type.
+ * Default: true (so all non-declared fields returns true)
+ */
+ public boolean isOverridable(String fieldName) {
+ FieldDescription field=getField(fieldName);
+ if (field==null) return true;
+ return field.isOverridable();
+ }
+
+ /**
+ * Returns the permissible class for the value of the given name in this type
+ *
+ * @return the permissible class for a value, <code>Object</code> if all types are legal,
+ * null if no types are legal (i.e if the name is not legal)
+ */
+ public Class<?> getValueClass(String name) {
+ FieldDescription fieldDescription=getField(name);
+ if (fieldDescription==null) {
+ if (strict)
+ return null; // Undefined -> Not legal
+ else
+ return Object.class; // Undefined -> Anything is legal
+ }
+ return fieldDescription.getType().getValueClass();
+ }
+
+ /** Returns the type of the given query profile type declared as a field in this */
+ public QueryProfileType getType(String localName) {
+ FieldDescription fieldDescription=getField(localName);
+ if (fieldDescription ==null) return null;
+ if ( ! (fieldDescription.getType() instanceof QueryProfileFieldType)) return null;
+ return ((QueryProfileFieldType) fieldDescription.getType()).getQueryProfileType();
+ }
+
+ /**
+ * Returns the description of the field with the given name in this type or an inherited type
+ * (depth first left to right search). Returns null if the field is not defined in this or an inherited profile.
+ */
+ public FieldDescription getField(String name) {
+ FieldDescription field=fields.get(name);
+ if ( field!=null ) return field;
+
+ if ( isFrozen() ) return null; // Inherited are collapsed into this
+
+ for (QueryProfileType inheritedType : this.inherited() ) {
+ field=inheritedType.getField(name);
+ if (field!=null) return field;
+ }
+
+ return null;
+ }
+
+ /**
+ * Removes a field from this (not from any inherited profile)
+ *
+ * @return the removed field or null if none
+ * @throws IllegalStateException if this is frozen
+ */
+ public FieldDescription removeField(String fieldName) {
+ ensureNotFrozen();
+ return fields.remove(fieldName);
+ }
+
+ /**
+ * Adds a field to this, without associating with a type registry; field descriptions with compound
+ * is not be supported.
+ *
+ * @throws IllegalStateException if this is frozen
+ */
+ public void addField(FieldDescription fieldDescription) {
+ // Compound names translates to new types, which must be added to a supplied registry
+ if (fieldDescription.getCompoundName().isCompound())
+ throw new IllegalArgumentException("Adding compound names is only legal when supplying a registry");
+ addField(fieldDescription, null);
+ }
+
+ /**
+ * Adds a field to this
+ *
+ * @throws IllegalStateException if this is frozen
+ */
+ public void addField(FieldDescription fieldDescription, QueryProfileTypeRegistry registry) {
+ CompoundName name = fieldDescription.getCompoundName();
+ if (name.isCompound()) {
+ // Add (/to) a query profile type containing the rest of the name.
+ // (we do not need the field description settings for intermediate query profile types
+ // as the leaf entry will enforce them)
+ QueryProfileType type = getOrCreateQueryProfileType(name.first(), registry);
+ type.addField(fieldDescription.withName(name.rest()), registry);
+ }
+ else {
+ ensureNotFrozen();
+ fields.put(fieldDescription.getName(), fieldDescription);
+ }
+
+ for (String alias : fieldDescription.getAliases())
+ addAlias(alias, fieldDescription.getName());
+ }
+
+ private QueryProfileType getOrCreateQueryProfileType(String name, QueryProfileTypeRegistry registry) {
+ FieldDescription fieldDescription = getField(name);
+ if (fieldDescription != null) {
+ if ( ! ( fieldDescription.getType() instanceof QueryProfileFieldType))
+ throw new IllegalArgumentException("Cannot use name '" + name + "' as a prefix because it is " +
+ "already a " + fieldDescription.getType());
+ QueryProfileFieldType fieldType = (QueryProfileFieldType) fieldDescription.getType();
+ QueryProfileType type = fieldType.getQueryProfileType();
+ if (type == null) { // an as-yet untyped reference; add type
+ type = new QueryProfileType(name);
+ registry.register(type.getId(), type);
+ fields.put(name, fieldDescription.withType(new QueryProfileFieldType(type)));
+ }
+ return type;
+ }
+ else {
+ QueryProfileType type = new QueryProfileType(name);
+ registry.register(type.getId(), type);
+ fields.put(name, new FieldDescription(name, new QueryProfileFieldType(type)));
+ return type;
+ }
+ }
+
+ private void addAlias(String alias,String field) {
+ ensureNotFrozen();
+ if (aliases==null)
+ aliases=new HashMap<>();
+ aliases.put(toLowerCase(alias),field);
+ }
+
+ /** Returns all the fields of this profile type and all types it inherits as a read-only map */
+ public Map<String,FieldDescription> fields() {
+ if (isFrozen()) return fields;
+ if (inherited().size()==0) return Collections.unmodifiableMap(fields);
+
+ // Collapse inherited
+ Map<String,FieldDescription> allFields=new HashMap<>(fields);
+ for (QueryProfileType inheritedType : inherited)
+ allFields.putAll(inheritedType.fields());
+ return Collections.unmodifiableMap(allFields);
+ }
+
+ /**
+ * Returns the alias to field mapping of this type as a read-only map. This is never null.
+ * Note that all keys are lower-cased because aliases are case-insensitive
+ */
+ public Map<String,String> aliases() {
+ if (isFrozen()) return aliases;
+ if (aliases == null) return Collections.emptyMap();
+ return Collections.unmodifiableMap(aliases);
+ }
+
+ /** Returns the field name of an alias or field name */
+ public String unalias(String aliasOrField) {
+ if (aliases==null || aliases.isEmpty()) return aliasOrField;
+ String field=aliases.get(toLowerCase(aliasOrField));
+ if (field!=null) return field;
+ return aliasOrField;
+ }
+
+ @Override
+ public int hashCode() {
+ return getId().hashCode();
+ }
+
+ /** Two types are equal if they have the same id */
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof QueryProfileType)) return false;
+ QueryProfileType other = (QueryProfileType)o;
+ return other.getId().equals(this.getId());
+ }
+
+ public String toString() {
+ return "query profile type '" + getId() + "'";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java
new file mode 100644
index 00000000000..3f64caa7ab1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+
+/**
+ * A registry of query profile types
+ *
+ * @author bratseth
+ */
+public class QueryProfileTypeRegistry extends ComponentRegistry<QueryProfileType> {
+
+ public QueryProfileTypeRegistry() {
+ Query.addNativeQueryProfileTypesTo(this);
+ }
+
+ /** Register this type by its id */
+ public void register(QueryProfileType type) {
+ super.register(type.getId(), type);
+ }
+
+ @Override
+ public void freeze() {
+ if (isFrozen()) return;
+ for (QueryProfileType queryProfileType : allComponents())
+ queryProfileType.freeze();
+ }
+
+ public static QueryProfileTypeRegistry emptyFrozen() {
+ QueryProfileTypeRegistry registry = new QueryProfileTypeRegistry();
+ registry.freeze();
+ return registry;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java
new file mode 100644
index 00000000000..747cf73acb3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java
@@ -0,0 +1,59 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.search.query.profile.QueryProfileRegistry;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.tensor.MapTensor;
+import com.yahoo.tensor.Tensor;
+import com.yahoo.tensor.TensorType;
+
+import java.util.Optional;
+
+/**
+ * A tensor field type in a query profile
+ *
+ * @author bratseth
+ */
+public class TensorFieldType extends FieldType {
+
+ private final Optional<TensorType> type;
+
+ /** Creates a tensor field type with optional information about the kind of tensor this will hold */
+ public TensorFieldType(Optional<TensorType> type) {
+ this.type = type;
+ }
+
+ /** Returns information about the type of tensor this will hold, or empty to allow any kind of tensor */
+ public Optional<TensorType> type() { return type; }
+
+ @Override
+ public Class getValueClass() { return Tensor.class; }
+
+ @Override
+ public String stringValue() { return "tensor"; }
+
+ @Override
+ public String toString() { return "field type " + stringValue(); }
+
+ @Override
+ public String toInstanceDescription() { return "a tensor"; }
+
+ @Override
+ public Object convertFrom(Object o, QueryProfileRegistry registry) {
+ if (o instanceof Tensor) return o;
+ if (o instanceof String) return MapTensor.from((String)o);
+ return null;
+ }
+
+ @Override
+ public Object convertFrom(Object o, CompiledQueryProfileRegistry registry) {
+ return convertFrom(o, (QueryProfileRegistry)null);
+ }
+
+ public static TensorFieldType fromTypeString(String s) {
+ if (s.equals("tensor")) return genericTensorType;
+ return new TensorFieldType(Optional.of(TensorType.fromSpec(s)));
+ }
+
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java
new file mode 100644
index 00000000000..1f9fa7a1fb4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java
@@ -0,0 +1,11 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Query profile types defines the set of fields a query profile may, can or must have. Query profile
+ * types may be inherited in a type hierarchy.
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.profile.types;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java
new file mode 100644
index 00000000000..01c861b879e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java
@@ -0,0 +1,40 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.Map;
+
+/**
+ * Default values for properties that are meant to be customized in query profiles.
+ * @author tonytv
+ */
+public final class DefaultProperties extends Properties {
+ public static final CompoundName MAX_OFFSET = new CompoundName("maxOffset");
+ public static final CompoundName MAX_HITS = new CompoundName("maxHits");
+
+
+ public static final QueryProfileType argumentType = new QueryProfileType("DefaultProperties");
+ static {
+ argumentType.setBuiltin(true);
+
+ argumentType.addField(new FieldDescription(MAX_OFFSET.toString(), "integer"));
+ argumentType.addField(new FieldDescription(MAX_HITS.toString(), "integer"));
+
+ argumentType.freeze();
+ }
+
+ @Override
+ public Object get(CompoundName name, Map<String, String> context, com.yahoo.processing.request.Properties substitution) {
+ if (MAX_OFFSET.equals(name)) {
+ return 1000;
+ } else if (MAX_HITS.equals(name)) {
+ return 400;
+ } else {
+ return super.get(name, context, substitution);
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java
new file mode 100644
index 00000000000..cc2c08c5504
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java
@@ -0,0 +1,58 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.Properties;
+
+import java.util.Map;
+
+/**
+ * A properties implementation which translates the incoming name to its standard name
+ * if it is a registered alias.
+ * <p>
+ * Aliases are case insensitive. One standard name may have multiple aliases.
+ * <p>
+ * This is multithread safe or not depending on the status of the passed map of aliases.
+ * Cloning will not deep copy the set of aliases.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class PropertyAliases extends Properties {
+
+ /** A map from aliases to standard names */
+ private final Map<String,CompoundName> aliases;
+
+ /**
+ * Creates an instance with a set of aliases. The given aliases will be used directly by this class.
+ * To make this class immutable and thread safe, relinquish ownership of the parameter map.
+ */
+ public PropertyAliases(Map<String,CompoundName> aliases) {
+ this.aliases=aliases;
+ }
+
+ /**
+ * Returns the standard name for an alias, or the given name if it is not a registered alias
+ *
+ * @param nameOrAlias the name to check if is an alias
+ * @return the real name if an alias or the input name itself
+ */
+ protected CompoundName unalias(CompoundName nameOrAlias) {
+ CompoundName properName = aliases.get(nameOrAlias.getLowerCasedName());
+ return (properName != null) ? properName : nameOrAlias;
+ }
+
+ public @Override Map<String, Object> listProperties(CompoundName property,Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ return super.listProperties(unalias(property),context,substitution);
+ }
+
+ public @Override Object get(CompoundName name,Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ return super.get(unalias(name),context,substitution);
+ }
+
+ public @Override void set(CompoundName name,Object value,Map<String,String> context) {
+ super.set(unalias(name),value,context);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java
new file mode 100644
index 00000000000..820c4fc8ea3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java
@@ -0,0 +1,132 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.result.Hit;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.logging.Logger;
+
+/**
+ * A Map backing of Properties.
+ * <p>
+ * When this is cloned it will deep copy not only the model object map, but also each
+ * clonable member inside the map.
+ * <p>
+ * Subclassing is supported, a hook can be implemented to provide conditional inclusion in the map.
+ * By default - all properties are accepted, so set is never propagated.
+ * <p>
+ * This class is not multithread safe.
+ *
+ * @author bratseth
+ */
+public class PropertyMap extends Properties {
+
+ private static Logger log=Logger.getLogger(PropertyMap.class.getName());
+
+ /** The properties of this */
+ private Map<CompoundName, Object> properties = new LinkedHashMap<>();
+
+ public void set(CompoundName name, Object value, Map<String,String> context) {
+ if (shouldSet(name, value))
+ properties.put(name, value);
+ else
+ super.set(name, value, context);
+ }
+
+ /**
+ * Return true if this value should be set in this map, false if the set should be propagated instead
+ * This default implementation always returns true.
+ */
+ protected boolean shouldSet(CompoundName name,Object value) { return true; }
+
+ public @Override Object get(CompoundName name, Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ if ( ! properties.containsKey(name)) return super.get(name,context,substitution);
+ return properties.get(name);
+ }
+
+ /**
+ * Returns a direct reference to the map containing the properties set in this instance.
+ */
+ public Map<CompoundName, Object> propertyMap() {
+ return properties;
+ }
+
+ public @Override PropertyMap clone() {
+ PropertyMap clone = (PropertyMap)super.clone();
+ clone.properties = new HashMap<>();
+ for (Map.Entry<CompoundName, Object> entry : this.properties.entrySet()) {
+ Object cloneValue = clone(entry.getValue());
+ if (cloneValue == null)
+ cloneValue = entry.getValue(); // Shallow copy objects which does not support cloning
+ clone.properties.put(entry.getKey(), cloneValue);
+ }
+ return clone;
+ }
+
+ /** Clones this object if it is clonable, and the clone is public. Returns null if not */
+ public static Object clone(Object object) {
+ if (object==null) return null;
+ if (! ( object instanceof Cloneable) ) return null;
+ if (object instanceof Object[])
+ return arrayClone((Object[])object);
+ else
+ return objectClone(object);
+ }
+
+ private static Object arrayClone(Object[] object) {
+ Object[] arrayClone= Arrays.copyOf(object, object.length);
+ // deep clone
+ for (int i=0; i<arrayClone.length; i++) {
+ Object elementClone=clone(arrayClone[i]);
+ if (elementClone!=null)
+ arrayClone[i]=elementClone;
+ }
+ return arrayClone;
+ }
+
+ private static Object objectClone(Object object) {
+ if (object instanceof Hit) {
+ return ((Hit) object).clone();
+ } else if (object instanceof LinkedList) {
+ return ((LinkedList) object).clone();
+ }
+ try {
+ Method cloneMethod=object.getClass().getMethod("clone");
+ return cloneMethod.invoke(object);
+ }
+ catch (NoSuchMethodException e) {
+ log.warning("'" + object + "' is Cloneable, but has no clone method - will use the same instance in all requests");
+ return null;
+ }
+ catch (IllegalAccessException e) {
+ log.warning("'" + object + "' is Cloneable, but clone method cannot be accessed - will use the same instance in all requests");
+ return null;
+ }
+ catch (InvocationTargetException e) {
+ throw new RuntimeException("Exception cloning '" + object + "'",e);
+ }
+ }
+
+ @Override
+ public Map<String, Object> listProperties(CompoundName path, Map<String, String> context, com.yahoo.processing.request.Properties substitution) {
+ Map<String, Object> map = super.listProperties(path, context, substitution);
+
+ for (Map.Entry<CompoundName, Object> entry : properties.entrySet()) {
+ if ( ! entry.getKey().hasPrefix(path)) continue;
+ CompoundName propertyName = entry.getKey().rest(path.size());
+ if (propertyName.isEmpty()) continue;
+ map.put(propertyName.toString(), entry.getValue());
+ }
+ return map;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java
new file mode 100644
index 00000000000..cd4e02dc768
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java
@@ -0,0 +1,296 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.*;
+import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry;
+import com.yahoo.search.query.ranking.Diversity;
+import com.yahoo.search.query.ranking.MatchPhase;
+import com.yahoo.tensor.Tensor;
+
+import java.util.Map;
+
+/**
+ * Maps between the query model and text properties.
+ * This can be done simpler by using reflection but the performance penalty was not worth it,
+ * especially since we should be conservative in adding things to the query model.
+ *
+ * @author bratseth
+ */
+public class QueryProperties extends Properties {
+
+ private static final String MODEL_PREFIX = Model.MODEL + ".";
+ private static final String RANKING_PREFIX = Ranking.RANKING + ".";
+ private static final String PRESENTATION_PREFIX = Presentation.PRESENTATION + ".";
+
+ public static final CompoundName[] PER_SOURCE_QUERY_PROPERTIES = new CompoundName[] {
+ new CompoundName(MODEL_PREFIX + Model.QUERY_STRING),
+ new CompoundName(MODEL_PREFIX + Model.TYPE),
+ new CompoundName(MODEL_PREFIX + Model.FILTER),
+ new CompoundName(MODEL_PREFIX + Model.DEFAULT_INDEX),
+ new CompoundName(MODEL_PREFIX + Model.LANGUAGE),
+ new CompoundName(MODEL_PREFIX + Model.ENCODING),
+ new CompoundName(MODEL_PREFIX + Model.SOURCES),
+ new CompoundName(MODEL_PREFIX + Model.SEARCH_PATH),
+ new CompoundName(MODEL_PREFIX + Model.RESTRICT),
+ new CompoundName(RANKING_PREFIX + Ranking.LOCATION),
+ new CompoundName(RANKING_PREFIX + Ranking.PROFILE),
+ new CompoundName(RANKING_PREFIX + Ranking.SORTING),
+ new CompoundName(RANKING_PREFIX + Ranking.FRESHNESS),
+ new CompoundName(RANKING_PREFIX + Ranking.QUERYCACHE),
+ new CompoundName(RANKING_PREFIX + Ranking.LIST_FEATURES),
+ new CompoundName(PRESENTATION_PREFIX + Presentation.BOLDING),
+ new CompoundName(PRESENTATION_PREFIX + Presentation.SUMMARY),
+ new CompoundName(PRESENTATION_PREFIX + Presentation.REPORT_COVERAGE),
+ new CompoundName(PRESENTATION_PREFIX + Presentation.FORMAT),
+ new CompoundName(PRESENTATION_PREFIX + Presentation.SUMMARY_FIELDS),
+ Query.HITS,
+ Query.OFFSET,
+ Query.TRACE_LEVEL,
+ Query.TIMEOUT,
+ Query.NO_CACHE,
+ Query.GROUPING_SESSION_CACHE };
+
+ private Query query;
+ private final CompiledQueryProfileRegistry profileRegistry;
+
+ public QueryProperties(Query query, CompiledQueryProfileRegistry profileRegistry) {
+ this.query = query;
+ this.profileRegistry = profileRegistry;
+ }
+
+ public void setParentQuery(Query query) {
+ this.query=query;
+ super.setParentQuery(query);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public Object get(final CompoundName key, Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ if (key.size()==2 && key.first().equals(Model.MODEL)) {
+ if (key.last().equals(Model.QUERY_STRING)) return query.getModel().getQueryString();
+ if (key.last().equals(Model.TYPE)) return query.getModel().getType();
+ if (key.last().equals(Model.FILTER)) return query.getModel().getFilter();
+ if (key.last().equals(Model.DEFAULT_INDEX)) return query.getModel().getDefaultIndex();
+ if (key.last().equals(Model.LANGUAGE)) return query.getModel().getLanguage();
+ if (key.last().equals(Model.ENCODING)) return query.getModel().getEncoding();
+ if (key.last().equals(Model.SOURCES)) return query.getModel().getSources();
+ if (key.last().equals(Model.SEARCH_PATH)) return query.getModel().getSearchPath();
+ if (key.last().equals(Model.RESTRICT)) return query.getModel().getRestrict();
+ }
+ else if (key.first().equals(Ranking.RANKING)) {
+ if (key.size()==2) {
+ if (key.last().equals(Ranking.LOCATION)) return query.getRanking().getLocation();
+ if (key.last().equals(Ranking.PROFILE)) return query.getRanking().getProfile();
+ if (key.last().equals(Ranking.SORTING)) return query.getRanking().getSorting();
+ if (key.last().equals(Ranking.FRESHNESS)) return query.getRanking().getFreshness();
+ if (key.last().equals(Ranking.QUERYCACHE)) return query.getRanking().getQueryCache();
+ if (key.last().equals(Ranking.LIST_FEATURES)) return query.getRanking().getListFeatures();
+ }
+ else if (key.size()>=3 && key.get(1).equals(Ranking.MATCH_PHASE)) {
+ if (key.size() == 3) {
+ MatchPhase matchPhase = query.getRanking().getMatchPhase();
+ if (key.last().equals(MatchPhase.ATTRIBUTE)) return matchPhase.getAttribute();
+ if (key.last().equals(MatchPhase.ASCENDING)) return matchPhase.getAscending();
+ if (key.last().equals(MatchPhase.MAX_HITS)) return matchPhase.getMaxHits();
+ if (key.last().equals(MatchPhase.MAX_FILTER_COVERAGE)) return matchPhase.getMaxFilterCoverage();
+ } else if (key.size() >= 4 && key.get(2).equals(Ranking.DIVERSITY)) {
+ Diversity diversity = query.getRanking().getMatchPhase().getDiversity();
+ if (key.size() == 4) {
+ if (key.last().equals(Diversity.ATTRIBUTE)) return diversity.getAttribute();
+ if (key.last().equals(Diversity.MINGROUPS)) return diversity.getMinGroups();
+ } else if ((key.size() == 5) && key.get(3).equals(Diversity.CUTOFF)) {
+ if (key.last().equals(Diversity.FACTOR)) return diversity.getCutoffFactor();
+ if (key.last().equals(Diversity.STRATEGY)) return diversity.getCutoffStrategy();
+ }
+ }
+ }
+ else if (key.size()>2) {
+ // pass the portion after "ranking.features/properties" down
+ if (key.get(1).equals(Ranking.FEATURES)) return query.getRanking().getFeatures().getObject(key.rest().rest().toString());
+ if (key.get(1).equals(Ranking.PROPERTIES)) return query.getRanking().getProperties().get(key.rest().rest().toString());
+ }
+ }
+ else if (key.size()==2 && key.first().equals(Presentation.PRESENTATION)) {
+ if (key.last().equals(Presentation.BOLDING)) return query.getPresentation().getBolding();
+ if (key.last().equals(Presentation.SUMMARY)) return query.getPresentation().getSummary();
+ if (key.last().equals(Presentation.REPORT_COVERAGE)) return query.getPresentation().getReportCoverage();
+ if (key.last().equals(Presentation.FORMAT)) return query.getPresentation().getFormat();
+ if (key.last().equals(Presentation.TIMING)) return query.getPresentation().getTiming();
+ if (key.last().equals(Presentation.SUMMARY_FIELDS)) return query.getPresentation().getSummaryFields();
+ }
+ else if (key.first().equals("rankfeature") || key.first().equals("featureoverride")) { // featureoverride is deprecated
+ return query.getRanking().getFeatures().getObject(key.rest().toString());
+ } else if (key.first().equals("rankproperty")) {
+ return query.getRanking().getProperties().get(key.rest().toString());
+ } else if (key.size()==1) {
+ if (key.equals(Query.HITS)) return query.getHits();
+ if (key.equals(Query.OFFSET)) return query.getOffset();
+ if (key.equals(Query.TRACE_LEVEL)) return query.getTraceLevel();
+ if (key.equals(Query.TIMEOUT)) return query.getTimeout();
+ if (key.equals(Query.NO_CACHE)) return query.getNoCache();
+ if (key.equals(Query.GROUPING_SESSION_CACHE)) return query.getGroupingSessionCache();
+ if (key.toString().equals(Model.MODEL)) return query.getModel();
+ if (key.toString().equals(Ranking.RANKING)) return query.getRanking();
+ if (key.toString().equals(Presentation.PRESENTATION)) return query.getPresentation();
+ }
+ return super.get(key,context,substitution);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public void set(final CompoundName key,Object value,Map<String,String> context) {
+ // Note: The defaults here are never used
+ try {
+ if (key.size()==2 && key.first().equals(Model.MODEL)) {
+ if (key.last().equals(Model.QUERY_STRING))
+ query.getModel().setQueryString(asString(value, ""));
+ else if (key.last().equals(Model.TYPE))
+ query.getModel().setType(asString(value, "ANY"));
+ else if (key.last().equals(Model.FILTER))
+ query.getModel().setFilter(asString(value, ""));
+ else if (key.last().equals(Model.DEFAULT_INDEX))
+ query.getModel().setDefaultIndex(asString(value, ""));
+ else if (key.last().equals(Model.LANGUAGE))
+ query.getModel().setLanguage(asString(value, ""));
+ else if (key.last().equals(Model.ENCODING))
+ query.getModel().setEncoding(asString(value,""));
+ else if (key.last().equals(Model.SEARCH_PATH))
+ query.getModel().setSearchPath(asString(value,""));
+ else if (key.last().equals(Model.SOURCES))
+ query.getModel().setSources(asString(value,""));
+ else if (key.last().equals(Model.RESTRICT))
+ query.getModel().setRestrict(asString(value,""));
+ else
+ throwIllegalParameter(key.last(),Model.MODEL);
+ }
+ else if (key.first().equals(Ranking.RANKING)) {
+ if (key.size()==2) {
+ if (key.last().equals(Ranking.LOCATION))
+ query.getRanking().setLocation(asString(value,""));
+ else if (key.last().equals(Ranking.PROFILE))
+ query.getRanking().setProfile(asString(value,""));
+ else if (key.last().equals(Ranking.SORTING))
+ query.getRanking().setSorting(asString(value,""));
+ else if (key.last().equals(Ranking.FRESHNESS))
+ query.getRanking().setFreshness(asString(value, ""));
+ else if (key.last().equals(Ranking.QUERYCACHE))
+ query.getRanking().setQueryCache(asBoolean(value, false));
+ else if (key.last().equals(Ranking.LIST_FEATURES))
+ query.getRanking().setListFeatures(asBoolean(value,false));
+ }
+ else if (key.size()>=3 && key.get(1).equals(Ranking.MATCH_PHASE)) {
+ if (key.size() == 3) {
+ MatchPhase matchPhase = query.getRanking().getMatchPhase();
+ if (key.last().equals(MatchPhase.ATTRIBUTE)) {
+ matchPhase.setAttribute(asString(value, null));
+ } else if (key.last().equals(MatchPhase.ASCENDING)) {
+ matchPhase.setAscending(asBoolean(value, false));
+ } else if (key.last().equals(MatchPhase.MAX_HITS)) {
+ matchPhase.setMaxHits(asLong(value, null));
+ } else if (key.last().equals(MatchPhase.MAX_FILTER_COVERAGE)) {
+ matchPhase.setMaxFilterCoverage(asDouble(value, 1.0));
+ }
+ } else if (key.size() > 3 && key.get(2).equals(Ranking.DIVERSITY)) {
+ Diversity diversity = query.getRanking().getMatchPhase().getDiversity();
+ if (key.last().equals(Diversity.ATTRIBUTE)) {
+ diversity.setAttribute(asString(value, null));
+ } else if (key.last().equals(Diversity.MINGROUPS)) {
+ diversity.setMinGroups(asLong(value, null));
+ } else if ((key.size() > 4) && key.get(3).equals(Diversity.CUTOFF)) {
+ if (key.last().equals(Diversity.FACTOR)) {
+ diversity.setCutoffFactor(asDouble(value, 10.0));
+ } else if (key.last().equals(Diversity.STRATEGY)) {
+ diversity.setCutoffStrategy(asString(value, "loose"));
+ }
+ }
+ }
+ }
+ else if (key.size()>2) {
+ String restKey = key.rest().rest().toString();
+ if (key.get(1).equals(Ranking.FEATURES))
+ setRankingFeature(query, restKey, toSpecifiedType(restKey, value, profileRegistry.getTypeRegistry().getComponent("features")));
+ else if (key.get(1).equals(Ranking.PROPERTIES))
+ query.getRanking().getProperties().put(restKey, toSpecifiedType(restKey, value, profileRegistry.getTypeRegistry().getComponent("properties")));
+ else
+ throwIllegalParameter(key.rest().toString(),Ranking.RANKING);
+ }
+ }
+ else if (key.size()==2 && key.first().equals(Presentation.PRESENTATION)) {
+ if (key.last().equals(Presentation.BOLDING))
+ query.getPresentation().setBolding(asBoolean(value, true));
+ else if (key.last().equals(Presentation.SUMMARY))
+ query.getPresentation().setSummary(asString(value, ""));
+ else if (key.last().equals(Presentation.REPORT_COVERAGE))
+ query.getPresentation().setReportCoverage(asBoolean(value,true));
+ else if (key.last().equals(Presentation.FORMAT))
+ query.getPresentation().setFormat(asString(value,""));
+ else if (key.last().equals(Presentation.TIMING))
+ query.getPresentation().setTiming(asBoolean(value, true));
+ else if (key.last().equals(Presentation.SUMMARY_FIELDS))
+ query.getPresentation().setSummaryFields(asString(value,""));
+ else
+ throwIllegalParameter(key.last(), Presentation.PRESENTATION);
+ }
+ else if (key.first().equals("rankfeature") || key.first().equals("featureoverride") ) { // featureoverride is deprecated
+ setRankingFeature(query, key.rest().toString(), toSpecifiedType(key.rest().toString(), value, profileRegistry.getTypeRegistry().getComponent("features")));
+ } else if (key.first().equals("rankproperty")) {
+ query.getRanking().getProperties().put(key.rest().toString(), toSpecifiedType(key.rest().toString(), value, profileRegistry.getTypeRegistry().getComponent("properties")));
+ } else if (key.size()==1) {
+ if (key.equals(Query.HITS))
+ query.setHits(asInteger(value,10));
+ else if (key.equals(Query.OFFSET))
+ query.setOffset(asInteger(value,0));
+ else if (key.equals(Query.TRACE_LEVEL))
+ query.setTraceLevel(asInteger(value,0));
+ else if (key.equals(Query.TIMEOUT))
+ query.setTimeout(value.toString());
+ else if (key.equals(Query.NO_CACHE))
+ query.setNoCache(asBoolean(value,false));
+ else if (key.equals(Query.GROUPING_SESSION_CACHE))
+ query.setGroupingSessionCache(asBoolean(value, false));
+ else
+ super.set(key,value,context);
+ }
+ else
+ super.set(key,value,context);
+ }
+ catch (Exception e) { // Make sure error messages are informative. This should be moved out of this properties implementation
+ if (e.getMessage().startsWith("Could not set"))
+ throw e;
+ else
+ throw new IllegalArgumentException("Could not set '" + key + "' to '" + value + "'", e);
+ }
+ }
+
+ private void setRankingFeature(Query query, String key, Object value) {
+ if (value instanceof Tensor)
+ query.getRanking().getFeatures().put(key, (Tensor)value);
+ else
+ query.getRanking().getFeatures().put(key, asString(value, ""));
+ }
+
+ private Object toSpecifiedType(String key, Object value, QueryProfileType type) {
+ if ( ! ( value instanceof String)) return value; // already typed
+ if (type == null) return value; // no type info -> keep as string
+ FieldDescription field = type.getField(key);
+ if (field == null) return value; // ditto
+ return field.getType().convertFrom(value, profileRegistry);
+ }
+
+ private void throwIllegalParameter(String key,String namespace) {
+ throw new IllegalArgumentException("'" + key + "' is not a valid property in '" + namespace +
+ "'. See the search api for valid keys starting by '" + namespace + "'.");
+ }
+
+ @Override
+ public final Query getParentQuery() {
+ return query;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java b/container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java
new file mode 100644
index 00000000000..15544e8ff4c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.processing.request.CompoundName;
+
+import java.util.Map;
+
+/**
+ * Property aliases which contains some hardcoded unaliasing of prefixes of
+ * rankfeature and rankproperty maps.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class QueryPropertyAliases extends PropertyAliases {
+
+ /**
+ * Creates an instance with a set of aliases. The given aliases will be used directly by this class.
+ * To make this class immutable and thread safe, relinquish ownership of the parameter map.
+ */
+ public QueryPropertyAliases(Map<String,CompoundName> aliases) {
+ super(aliases);
+ }
+
+ @Override
+ protected CompoundName unalias(CompoundName nameOrAlias) {
+ if (nameOrAlias.first().equalsIgnoreCase("rankfeature"))
+ return nameOrAlias.rest().prepend("ranking", "features");
+ else if (nameOrAlias.first().equalsIgnoreCase("rankproperty"))
+ return nameOrAlias.rest().prepend("ranking", "properties");
+ return super.unalias(nameOrAlias);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java
new file mode 100644
index 00000000000..c97f4daf6d4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.Properties;
+
+import java.util.Map;
+
+/**
+ * Turns get(name) into get(name,request) using the request given at construction time.
+ * This is used to allow the query's request to be supplied to all property requests
+ * without forcing users of the query.properties() to supply this explicitly.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class RequestContextProperties extends Properties {
+
+ private final Map<String,String> requestMap;
+
+ public RequestContextProperties(Map<String, String> properties) {
+ this.requestMap=properties;
+ }
+
+ @Override
+ public Object get(CompoundName name,Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ return super.get(name,context==null ? requestMap : context,substitution);
+ }
+
+ @Override
+ public void set(CompoundName name,Object value,Map<String,String> context) {
+ super.set(name,value,context==null ? requestMap : context);
+ }
+
+ @Override
+ public Map<String, Object> listProperties(CompoundName path,Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ return super.listProperties(path,context==null ? requestMap : context,substitution);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java
new file mode 100644
index 00000000000..7f5c2ec2558
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java
@@ -0,0 +1,67 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.properties;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.processing.request.Properties;
+
+import java.util.Map;
+
+/**
+ * A wrapper around a chain of property objects that prefixes all gets/sets with a given path
+ *
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+public class SubProperties extends com.yahoo.search.query.Properties {
+
+ final private CompoundName pathPrefix;
+ final private Properties parent;
+
+ public SubProperties(String pathPrefix, Properties properties) {
+ this(new CompoundName(pathPrefix),properties);
+ }
+
+ public SubProperties(CompoundName pathPrefix, Properties properties) {
+ this.pathPrefix = pathPrefix;
+ this.parent = properties;
+ }
+
+ @Override
+ public Object get(CompoundName key, Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ if(key == null) return null;
+ Object result = parent.get(getPathPrefix() + "." + key,context,substitution);
+ if(result == null) {
+ return super.get(key,context,substitution);
+ } else {
+ return result;
+ }
+ }
+
+ @Override
+ public void set(CompoundName key, Object obj, Map<String,String> context) {
+ if(key == null) return;
+ parent.set(getPathPrefix() + "." + key, obj, context);
+ }
+
+ @Override
+ public Map<String, Object> listProperties(CompoundName path,Map<String,String> context,
+ com.yahoo.processing.request.Properties substitution) {
+ Map<String, Object> map = super.listProperties(path,context,substitution);
+ if(path.isEmpty()) {
+ map.putAll(parent.listProperties(getPathPrefix(),context,substitution));
+ } else {
+ map.putAll(parent.listProperties(getPathPrefix() + "." + path,context,substitution));
+ }
+ return map;
+ }
+
+ public CompoundName getPathPrefixCompound() {
+ return pathPrefix;
+ }
+
+ /** Returns getPatchPrefixCompound.toString() */
+ public String getPathPrefix() {
+ return getPathPrefixCompound().toString();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/package-info.java b/container-search/src/main/java/com/yahoo/search/query/properties/package-info.java
new file mode 100644
index 00000000000..047a5494e53
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/properties/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.properties;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java b/container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java
new file mode 100644
index 00000000000..b1865ad9d75
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java
@@ -0,0 +1,127 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.ranking;
+
+import com.yahoo.search.query.Ranking;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.Objects;
+
+/**
+ * <p>The diversity settings during match phase of a query.
+ * These are the same settings for diversity during match phase that can be set in a rank profile
+ * and is used for achieving guaranteed diversity at the cost of slightly higher cost as more hits must be
+ * considered compared to plain match-phase.</p>
+ *
+ * <p>You specify an additional attribute to be the diversifier and also min diversity needed.</p>
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+public class Diversity implements Cloneable {
+
+ /** The type representing the property arguments consumed by this */
+ private static final QueryProfileType argumentType;
+
+ public static final String ATTRIBUTE = "attribute";
+ public static final String MINGROUPS = "minGroups";
+ public static final String CUTOFF = "cutoff";
+ public static final String FACTOR = "factor";
+ public static final String STRATEGY = "strategy";
+
+
+ static {
+ argumentType =new QueryProfileType(Ranking.DIVERSITY);
+ argumentType.setStrict(true);
+ argumentType.setBuiltin(true);
+ argumentType.addField(new FieldDescription(ATTRIBUTE, "string"));
+ argumentType.addField(new FieldDescription(MINGROUPS, "long"));
+ argumentType.freeze();
+ }
+ public static QueryProfileType getArgumentType() { return argumentType; }
+
+ public enum CutoffStrategy {loose, strict};
+ private String attribute = null;
+ private Long minGroups = null;
+ private Double cutoffFactor = null;
+ private CutoffStrategy cutoffStrategy= null;
+
+ /**
+ * Sets the attribute field which will be used to guarantee diversity.
+ * Set to null (default) to disable diversification.
+ * <p>
+ * If this is set, make sure to also set the maxGroups value.
+ * <p>
+ * This attribute must be singlevalue.
+ */
+ public void setAttribute(String attribute) { this.attribute = attribute; }
+
+ /** Returns the attribute to use for diversity, or null if none */
+ public String getAttribute() { return attribute; }
+
+ /**
+ * Sets the max hits to aim for producing in the match phase.
+ * This must be set if an attribute value is set.
+ * It should be set to a reasonable fraction of the total documents on each partition.
+ */
+ public void setMinGroups(long minGroups) { this.minGroups = minGroups; }
+
+ /** Returns the max hits to aim for producing in the match phase on each content node, or null if not set */
+ public Long getMinGroups() { return minGroups; }
+
+ public void setCutoffFactor(double cutoffFactor) { this.cutoffFactor = cutoffFactor; }
+ public Double getCutoffFactor() { return cutoffFactor; }
+ public void setCutoffStrategy(String cutoffStrategy) { this.cutoffStrategy = CutoffStrategy.valueOf(cutoffStrategy); }
+ public CutoffStrategy getCutoffStrategy() { return cutoffStrategy; }
+
+ /** Internal operation - DO NOT USE */
+ public void prepare(RankProperties rankProperties) {
+ if (attribute == null && minGroups == null) return;
+
+ if (attribute != null && !attribute.isEmpty()) {
+ rankProperties.put("vespa.matchphase.diversity.attribute", attribute);
+ }
+ if (minGroups != null) {
+ rankProperties.put("vespa.matchphase.diversity.mingroups", String.valueOf(minGroups));
+ }
+ if (cutoffFactor != null) {
+ rankProperties.put("vespa.matchphase.diversity.cutoff.factor", String.valueOf(cutoffFactor));
+ }
+ if (cutoffStrategy != null) {
+ rankProperties.put("vespa.matchphase.diversity.cutoff.strategy", cutoffStrategy);
+ }
+ }
+
+ @Override
+ public Diversity clone() {
+ try {
+ return (Diversity)super.clone();
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException("Won't happen", e);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 0;
+ if (attribute != null) hash += 11 * attribute.hashCode();
+ if (minGroups != null) hash += 13 * minGroups.hashCode();
+ if (cutoffFactor != null) hash += 17 * cutoffFactor.hashCode();
+ if (cutoffStrategy != null) hash += 19 * cutoffStrategy.hashCode();
+ return hash;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof Diversity)) return false;
+
+ Diversity other = (Diversity)o;
+ if ( ! Objects.equals(this.attribute, other.attribute)) return false;
+ if ( ! Objects.equals(this.minGroups, other.minGroups)) return false;
+ if ( ! Objects.equals(this.cutoffFactor, other.cutoffFactor)) return false;
+ if ( ! Objects.equals(this.cutoffStrategy, other.cutoffStrategy)) return false;
+ return true;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java b/container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java
new file mode 100644
index 00000000000..ba25ddbe7e6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java
@@ -0,0 +1,153 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.ranking;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.query.Ranking;
+import com.yahoo.search.query.profile.types.FieldDescription;
+import com.yahoo.search.query.profile.types.QueryProfileType;
+
+import java.util.Objects;
+
+/**
+ * The match phase ranking settings of this query.
+ * These are the same settings for match phase that can be set in a rank profile
+ * and is used for achieving reasonable query behavior given a query which causes too many matches:
+ * The engine will fall back to retrieving the best values according to the attribute given here
+ * during matching.
+ * <p>
+ * For this feature to work well, the order given by the attribute should correlate reasonably with the order
+ * of results produced if full evaluation is performed.
+ *
+ * @author bratseth
+ */
+public class MatchPhase implements Cloneable {
+
+ /** The type representing the property arguments consumed by this */
+ private static final QueryProfileType argumentType;
+
+ public static final String ATTRIBUTE = "attribute";
+ public static final String ASCENDING = "ascending";
+ public static final String MAX_HITS = "maxHits";
+ public static final String MAX_FILTER_COVERAGE = "maxFilterCoverage";
+
+ static {
+ argumentType =new QueryProfileType(Ranking.MATCH_PHASE);
+ argumentType.setStrict(true);
+ argumentType.setBuiltin(true);
+ argumentType.addField(new FieldDescription(ATTRIBUTE, "string"));
+ argumentType.addField(new FieldDescription(ASCENDING, "boolean"));
+ argumentType.addField(new FieldDescription(MAX_HITS, "long"));
+ argumentType.addField(new FieldDescription(MAX_FILTER_COVERAGE, "double"));
+ argumentType.addField(new FieldDescription(Ranking.DIVERSITY, "query-profile", "diversity"));
+ argumentType.freeze();
+ }
+ public static QueryProfileType getArgumentType() { return argumentType; }
+
+ private String attribute = null;
+ private boolean ascending = false;
+ private Long maxHits = null;
+ private Double maxFilterCoverage = 1.0;
+ private Diversity diversity = new Diversity();
+
+ /**
+ * Sets the attribute field which will be used to decide the best matches after it has been determined
+ * during matching that this query is going to cause too many matches.
+ * Set to null (default) to disable degradation.
+ * <p>
+ * If this is set, make sure to also set the maxHits value.
+ * Otherwise, the attribute setting is ignored.
+ * <p>
+ * This attribute should have fast-search turned on.
+ */
+ public void setAttribute(String attribute) { this.attribute = attribute; }
+
+ /** Returns the attribute to use for degradation, or null if none */
+ public String getAttribute() { return attribute; }
+
+ /**
+ * Set to true to sort by the attribute in ascending order when this is in use during the match phase,
+ * false (default) to use descending order.
+ */
+ public void setAscending(boolean ascending) { this.ascending = ascending; }
+
+ /**
+ * Returns the order to sort the attribute during the path phase when this takes effect.
+ */
+ public boolean getAscending() { return ascending; }
+
+ /**
+ * Sets the max hits to aim for producing in the match phase.
+ * This must be set if an attribute value is set.
+ * It should be set to a reasonable fraction of the total documents on each partition.
+ */
+ public void setMaxHits(long maxHits) { this.maxHits = maxHits; }
+
+ public void setMaxFilterCoverage(double maxFilterCoverage) {
+ if ((maxFilterCoverage < 0.0) || (maxFilterCoverage > 1.0)) {
+ throw new IllegalArgumentException("maxFilterCoverage must be in the range [0.0, 1.0]. It is " + maxFilterCoverage);
+ }
+ this.maxFilterCoverage = maxFilterCoverage;
+ }
+
+ /** Returns the max hits to aim for producing in the match phase on each content node, or null if not set */
+ public Long getMaxHits() { return maxHits; }
+
+ public Double getMaxFilterCoverage() { return maxFilterCoverage; }
+
+ public Diversity getDiversity() { return diversity; }
+
+ public void setDiversity(Diversity diversity) {
+ this.diversity = diversity;
+ }
+
+ /** Internal operation - DO NOT USE */
+ public void prepare(RankProperties rankProperties) {
+ if (attribute == null || maxHits == null) return;
+
+ rankProperties.put("vespa.matchphase.degradation.attribute", attribute);
+ if (ascending) { // backend default is descending
+ rankProperties.put("vespa.matchphase.degradation.ascendingorder", "true");
+ }
+ rankProperties.put("vespa.matchphase.degradation.maxhits", String.valueOf(maxHits));
+ rankProperties.put("vespa.matchphase.degradation.maxfiltercoverage", String.valueOf(maxFilterCoverage));
+ diversity.prepare(rankProperties);
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 0;
+ hash += 13 * Boolean.hashCode(ascending);
+ hash += 19 * diversity.hashCode();
+ if (attribute != null) hash += 11 * attribute.hashCode();
+ if (maxHits != null) hash += 17 * maxHits.hashCode();
+ hash += 23 * maxFilterCoverage.hashCode();
+ return hash;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof MatchPhase)) return false;
+
+ MatchPhase other = (MatchPhase)o;
+ if ( this.ascending != other.ascending) return false;
+ if ( ! Objects.equals(this.attribute, other.attribute)) return false;
+ if ( ! Objects.equals(this.maxHits, other.maxHits)) return false;
+ if ( ! Objects.equals(this.diversity, other.diversity)) return false;
+ if ( ! Objects.equals(this.maxFilterCoverage, other.maxFilterCoverage)) return false;
+ return true;
+ }
+
+ @Override
+ public MatchPhase clone() {
+ try {
+ MatchPhase clone = (MatchPhase)super.clone();
+ clone.diversity = diversity.clone();
+ return clone;
+ }
+ catch (CloneNotSupportedException e) {
+ throw new RuntimeException("Won't happen", e);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java b/container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java
new file mode 100644
index 00000000000..1bcd548882c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java
@@ -0,0 +1,130 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.ranking;
+
+import com.yahoo.fs4.MapEncoder;
+import com.yahoo.tensor.Tensor;
+import com.yahoo.text.JSON;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Contains the rank features of a query.
+ *
+ * @author bratseth
+ */
+public class RankFeatures implements Cloneable {
+
+ private final Map<String, Object> features;
+
+ public RankFeatures() {
+ this(new LinkedHashMap<>());
+ }
+
+ private RankFeatures(Map<String, Object> features) {
+ this.features = features;
+ }
+
+ /** Sets a rank feature by full name to a value */
+ public void put(String name, String value) {
+ features.put(name, value);
+ }
+
+ /** Sets a tensor rank feature */
+ public void put(String name, Tensor value) {
+ features.put(name, value);
+ }
+
+ /** Returns a rank feature as a string by full name or null if not set */
+ public String get(String name) {
+ Object value = features.get(name);
+ if (value == null) return null;
+ return value.toString();
+ }
+
+ /** Returns this value as whatever type it was stored as. Returns null if the value is not set. */
+ public Object getObject(String name) {
+ return features.get(name);
+ }
+
+ /**
+ * Returns a tensor rank feature, or empty if there is no value with this name.
+ *
+ * @throws IllegalArgumentException if the value is set but is not a tensor
+ */
+ public Optional<Tensor> getTensor(String name) {
+ Object feature = features.get(name);
+ if (feature == null) return Optional.empty();
+ if (feature instanceof Tensor) return Optional.of((Tensor)feature);
+ throw new IllegalArgumentException("Expected a tensor value of '" + name + "' but has " + feature);
+ }
+
+ /**
+ * Returns the map holding the features of this.
+ * This map may be modified to change the rank features of the query.
+ */
+ public Map<String, Object> asMap() { return features; }
+
+ public boolean isEmpty() {
+ return features.isEmpty();
+ }
+
+ /**
+ * Prepares this for encoding, not for external use. See encode on Query for details.
+ * <p>
+ * If the query feature is found in the rank feature set,
+ * remove all these entries and insert them into the rank property set instead.
+ * We want to hide from the user that the query feature value is sent down as a rank property
+ * and picked up by the query feature executor in the backend.
+ */
+ public void prepare(RankProperties rankProperties) {
+ if (isEmpty()) return;
+
+ List<String> featuresToRemove = new ArrayList<>();
+ List<String> propertiesToInsert = new ArrayList<>();
+ for (String key : features.keySet()) {
+ if (key.startsWith("query(") && key.endsWith(")")) {
+ featuresToRemove.add(key);
+ propertiesToInsert.add(key.substring("query(".length(), key.length() - 1));
+ } else if (key.startsWith("$")) {
+ featuresToRemove.add(key);
+ propertiesToInsert.add(key.substring(1));
+ }
+ }
+ for (int i = 0; i < featuresToRemove.size(); ++i) {
+ rankProperties.put(propertiesToInsert.get(i), features.remove(featuresToRemove.get(i)));
+ }
+ }
+
+ public int encode(ByteBuffer buffer) {
+ return MapEncoder.encodeMap("feature", features, buffer);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) return true;
+ if ( ! (other instanceof RankFeatures)) return false;
+
+ return this.features.equals(((RankFeatures)other).features);
+ }
+
+ @Override
+ public int hashCode() {
+ return features.hashCode();
+ }
+
+ @Override
+ public RankFeatures clone() {
+ return new RankFeatures(new LinkedHashMap<>(features));
+ }
+
+ @Override
+ public String toString() {
+ return JSON.encode(features);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java b/container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java
new file mode 100644
index 00000000000..eccb8bac2d4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java
@@ -0,0 +1,114 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.ranking;
+
+import com.yahoo.fs4.GetDocSumsPacket;
+import com.yahoo.fs4.MapEncoder;
+import com.yahoo.text.JSON;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Contains the properties properties of a query.
+ * This is a multimap: Multiple properties may be set for the same key.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class RankProperties implements Cloneable {
+
+ private Map<String, List<Object>> properties = new LinkedHashMap<>();
+
+ public RankProperties() {
+ this(new LinkedHashMap<>());
+ }
+
+ private RankProperties(Map<String, List<Object>> properties) {
+ this.properties = properties;
+ }
+
+ public void put(String name, String value) {
+ put(name, (Object)value);
+ }
+
+ /** Adds a property by full name to a value */
+ public void put(String name, Object value) {
+ List<Object> list = properties.get(name);
+ if (list == null) {
+ list = new ArrayList<>();
+ properties.put(name, list);
+ }
+ list.add(value);
+ }
+
+ /**
+ * Returns a read-only list of properties properties by full name.
+ * If this is not set, null is returned. If this is explicitly set to
+ * have no values, and empty list is returned.
+ */
+ public List<String> get(String name) {
+ List<Object> values = properties.get(name);
+ if (values == null) return null;
+ if (values.isEmpty()) return Collections.<String>emptyList();
+
+ // Compatibility ...
+ List<String> stringValues = new ArrayList<>(values.size());
+ for (Object value : values)
+ stringValues.add(value.toString());
+ return Collections.unmodifiableList(stringValues);
+ }
+
+ /** Removes all properties properties for a given name */
+ public void remove(String name) {
+ properties.remove(name);
+ }
+
+ public boolean isEmpty() {
+ return properties.isEmpty();
+ }
+
+ /** Returns a modifiable map of the properties of this */
+ public Map<String, List<Object>> asMap() { return properties; }
+
+ /** Encodes this in a binary internal representation and returns the number of property maps encoded (0 or 1) */
+ public int encode(ByteBuffer buffer, boolean encodeQueryData) {
+ if (encodeQueryData) {
+ return MapEncoder.encodeObjectMultiMap("rank", properties, buffer);
+ }
+ else {
+ List<Object> sessionId = properties.get(GetDocSumsPacket.sessionIdKey);
+ if (sessionId == null) return 0;
+ return MapEncoder.encodeSingleValue("rank", GetDocSumsPacket.sessionIdKey, sessionId.get(0), buffer);
+ }
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) return true;
+ if ( ! (other instanceof RankProperties)) return false;
+
+ return this.properties.equals(((RankProperties)other).properties);
+ }
+
+ @Override
+ public int hashCode() {
+ return properties.hashCode();
+ }
+
+ @Override
+ public RankProperties clone() {
+ Map<String, List<Object>> clone = new LinkedHashMap<>();
+ for (Map.Entry<String, List<Object>> entry : properties.entrySet())
+ clone.put(entry.getKey(), new ArrayList<>(entry.getValue()));
+ return new RankProperties(clone);
+ }
+
+ @Override
+ public String toString() {
+ return JSON.encode(properties);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java b/container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java
new file mode 100644
index 00000000000..f254b327f96
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.ranking;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java
new file mode 100644
index 00000000000..bb76c1006f2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java
@@ -0,0 +1,423 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite;
+
+import com.google.inject.Inject;
+import com.yahoo.search.*;
+import com.yahoo.config.*;
+import com.yahoo.search.query.rewrite.RewritesConfig.FsaDict;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.fsa.FSA;
+import com.yahoo.filedistribution.fileacquirer.FileAcquirer;
+import com.yahoo.component.ComponentId;
+
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * <p>A template class for all rewriters</p>
+ *
+ * <p>All rewriters extending this class would need to implement the
+ * rewrite method which contains the rewriter's main logic,
+ * getSkipRewriterIfRewritten method which indicates whether this
+ * rewriter should be skipped if the query has been rewritten,
+ * getRewriterName method which returns the name of the rewriter used
+ * in query profile, configure method which contains any instance
+ * creation time configuration besides the default FSA loading, and
+ * getDefaultDicts method which return the pair of dictionary name
+ * and filename.</p>
+ *
+ * <p>Common rewrite features are in RewriterFeatures.java.
+ * Common rewriter utils are in RewriterUtils.java.</p>
+ *
+ * @author Karen Sze Wing Lee
+ */
+public abstract class QueryRewriteSearcher extends Searcher {
+
+ // Indicate whether rewriter is properly initiated
+ private boolean isOk = false;
+
+ protected final Logger logger = Logger.getLogger(QueryRewriteSearcher.class.getName());
+
+ // HashMap which store the rewriter dicts
+ // It has the following format:
+ // HashMap<String(e.g. dictionary name, etc),
+ // Object(e.g. FSA, etc)>>
+ protected HashMap<String, Object> rewriterDicts = new HashMap<>();
+
+ /**
+ * Constructor for this rewriter.
+ * Prepare the data needed by the rewriter
+ * @param id Component ID (see vespa's search container doc for more detail)
+ * @param fileAcquirer Required param for retrieving file type config
+ * (see vespa's search container doc for more detail)
+ * @param config Config from vespa-services.xml (see vespa's search
+ * container doc for more detail)
+ */
+ @Inject
+ protected QueryRewriteSearcher(ComponentId id,
+ FileAcquirer fileAcquirer,
+ RewritesConfig config) {
+ super(id);
+ RewriterUtils.log(logger, "In QueryRewriteSearcher(ComponentId id, " +
+ "FileAcquirer fileAcquirer, " +
+ "RewritesConfig config)");
+ isOk = loadFSADicts(fileAcquirer, config, null);
+ isOk = isOk && configure(fileAcquirer, config, null);
+ if(isOk) {
+ RewriterUtils.log(logger, "Rewriter is configured properly");
+ } else {
+ RewriterUtils.log(logger, "Rewriter is not configured properly");
+ }
+ }
+
+ /**
+ * Constructor for unit test.
+ * Prepare the data needed by the rewriter
+ * @param config Config from vespa-services.xml (see vespa's search
+ * container doc for more detail)
+ * @param fileList pairs of file name and file handler for unit tests
+ */
+ protected QueryRewriteSearcher(RewritesConfig config,
+ HashMap<String, File> fileList) {
+ RewriterUtils.log(logger, "In QueryRewriteSearcher(RewritesConfig config, " +
+ "HashMap<String, File> fileList)");
+ isOk = loadFSADicts(null, config, fileList);
+ isOk = isOk && configure(null, config, fileList);
+ if(isOk) {
+ RewriterUtils.log(logger, "Rewriter is configured properly");
+ } else {
+ RewriterUtils.log(logger, "Rewriter is not configured properly");
+ }
+ }
+
+ /**
+ * Empty constructor.
+ * Do nothing at instance creation time
+ */
+ protected QueryRewriteSearcher(ComponentId id) {
+ super(id);
+ RewriterUtils.log(logger, "In QueryRewriteSearcher(Component id)");
+ RewriterUtils.log(logger, "Configuring rewriter: " + getRewriterName());
+ isOk = true;
+ RewriterUtils.log(logger, "Rewriter is configured properly");
+ }
+
+ /**
+ * Empty constructor for unit test.
+ * Do nothing at instance creation time
+ */
+ protected QueryRewriteSearcher() {
+ RewriterUtils.log(logger, "In QueryRewriteSearcher()");
+ RewriterUtils.log(logger, "Configuring rewriter: " + getRewriterName());
+ isOk = true;
+ RewriterUtils.log(logger, "Rewriter is configured properly");
+ }
+
+ /**
+ * Load the dicts specified in vespa-services.xml
+ *
+ * @param fileAcquirer Required param for retrieving file type config
+ * (see vespa's search container doc for more detail)
+ * @param config Config from vespa-services.xml (see vespa's search
+ * container doc for more detail)
+ * @param fileList pairs of file name and file handler for unit tests
+ * @return boolean true if loaded successfully, false otherwise
+ */
+ private boolean loadFSADicts(FileAcquirer fileAcquirer,
+ RewritesConfig config,
+ HashMap<String, File> fileList)
+ throws RuntimeException {
+
+ // Check if getRewriterName method is properly implemented
+ String rewriterName = getRewriterName();
+ if(rewriterName==null) {
+ RewriterUtils.error(logger, "Rewriter required method is not properly implemented: ");
+ return false;
+ }
+
+ RewriterUtils.log(logger, "Configuring rewriter: " + rewriterName);
+
+ // Check if there's no config need to be loaded
+ if(config==null || (fileAcquirer==null && fileList==null)) {
+ RewriterUtils.log(logger, "No FSA dictionary file need to be loaded");
+ return true;
+ }
+
+ // Check if config contains the FSADict param
+ if(config.fsaDict()==null) {
+ RewriterUtils.error(logger, "FSADict is not properly set in config");
+ return false;
+ }
+
+ RewriterUtils.log(logger, "Loading rewriter dictionaries");
+
+ // Retrieve FSA names and paths
+ ListIterator<FsaDict> fsaList = config.fsaDict().listIterator();
+
+ // Load default dictionaries if no user dictionaries is configured
+ if(!fsaList.hasNext()) {
+ RewriterUtils.log(logger, "Loading default dictionaries");
+ HashMap<String, String> defaultFSAs = getDefaultFSAs();
+
+ if(defaultFSAs==null) {
+ RewriterUtils.log(logger, "No default FSA dictionary is configured");
+ return true;
+ }
+ Iterator<Map.Entry<String, String>> defaultFSAList = defaultFSAs.entrySet().iterator();
+ while(defaultFSAList.hasNext()) {
+ try{
+ Map.Entry<String, String> currFSA = defaultFSAList.next();
+ String fsaName = currFSA.getKey();
+ String fsaPath = currFSA.getValue();
+
+ RewriterUtils.log(logger,
+ "FSA file location for " + fsaName + ": " + fsaPath);
+
+ // Load FSA
+ FSA fsa = RewriterUtils.loadFSA(RewriterConstants.DEFAULT_DICT_DIR + fsaPath, null);
+
+ // Store FSA into dictionary map
+ rewriterDicts.put(fsaName, fsa);
+ } catch (IOException e) {
+ RewriterUtils.error(logger, "Error loading FSA dictionary: " +
+ e.getMessage());
+ return false;
+ }
+ }
+ } else {
+ // Load user configured dictionaries
+ while(fsaList.hasNext()) {
+ try{
+ FsaDict currFSA = fsaList.next();
+ // fsaName and fsaPath are not null
+ // or else vespa config server would not have been
+ // able to start up
+ String fsaName = currFSA.name();
+ FileReference fsaPath = currFSA.path();
+
+ RewriterUtils.log(logger,
+ "FSA file location for " + fsaName + ": " + fsaPath);
+
+ // Retrieve FSA File handler
+ File fsaFile = null;
+ if(fileAcquirer!=null) {
+ fsaFile = fileAcquirer.waitFor(fsaPath, 5, TimeUnit.MINUTES);
+ } else if(fileList!=null) {
+ fsaFile = fileList.get(fsaName);
+ }
+
+ if(fsaFile==null) {
+ RewriterUtils.error(logger, "Error loading FSA dictionary file handler");
+ return false;
+ }
+
+ // Load FSA
+ FSA fsa = RewriterUtils.loadFSA(fsaFile, null);
+
+ // Store FSA into dictionary map
+ rewriterDicts.put(fsaName, fsa);
+ } catch (InterruptedException e1) {
+ RewriterUtils.error(logger, "Error loading FSA dictionary file handler: " +
+ e1.getMessage());
+ return false;
+ } catch (IOException e2) {
+ RewriterUtils.error(logger, "Error loading FSA dictionary: " +
+ e2.getMessage());
+ return false;
+ }
+ }
+ }
+ RewriterUtils.log(logger, "Successfully loaded rewriter dictionaries");
+ return true;
+ }
+
+ /**
+ * Perform instance creation time configuration besides the
+ * default FSA loading
+ *
+ * @param fileAcquirer Required param for retrieving file type config
+ * (see vespa's search container doc for more detail)
+ * @param config Config from vespa-services.xml (see vespa's search
+ * container doc for more detail)
+ * @param fileList pairs of file name and file handler for unit tests
+ * @return boolean true if loaded successfully, false otherwise
+ */
+ public abstract boolean configure(FileAcquirer fileAcquirer,
+ RewritesConfig config,
+ HashMap<String, File> fileList)
+ throws RuntimeException;
+
+ /**
+ * Perform main rewrite logics for this searcher<br>
+ * - Skip to next rewriter if query is previously
+ * rewritten and getSkipRewriterIfRewritten() is
+ * true for this rewriter<br>
+ * - Execute rewriter's main rewrite logic<br>
+ * - Pass to the next rewriter the query to be used
+ * for dictionary retrieval<br>
+ */
+ public @Override Result search(Query query, Execution execution) {
+ RewriterUtils.log(logger, query, "Executing " + getRewriterName());
+
+ // Check if rewriter is properly initialized
+ if(!isOk) {
+ RewriterUtils.error(logger, query, "Rewriter is not properly initialized");
+ return execution.search(query);
+ }
+
+ RewriterUtils.log(logger, query, "Original query: " + query.toDetailString());
+
+ // Retrieve metadata passed by previous rewriter
+ HashMap<String, Object> rewriteMeta = RewriterUtils.getRewriteMeta(query);
+
+ // This key would be updated by each rewriter to specify
+ // the key to be used for dict retrieval in next
+ // rewriter downstream. This controls whether the
+ // next rewriter should use the rewritten query or the
+ // original query for dict retrieval. e.g. rewriters
+ // following misspell rewriter should use the rewritten
+ // query by misspell rewriter for dict retrieval
+ String prevDictKey = (String)rewriteMeta.get(RewriterConstants.DICT_KEY);
+
+ // Whether the query has been rewritten
+ Boolean prevRewritten = (Boolean)rewriteMeta.get(RewriterConstants.REWRITTEN);
+
+ // Check if rewriter should be skipped if the query
+ // has been rewritten
+ if(prevRewritten && getSkipRewriterIfRewritten()) {
+ RewriterUtils.log(logger, query, "Skipping rewriter since the " +
+ "query has been rewritten");
+ return execution.search(query);
+ }
+
+ // Store rewriter result
+ HashMap<String, Object> rewriterResult = null;
+ Query originalQueryObj = query.clone();
+
+ try {
+ // Execute rewriter's main rewrite logic
+ rewriterResult = rewrite(query, prevDictKey);
+
+ } catch (RuntimeException e) {
+ RewriterUtils.error(logger, originalQueryObj, "Error executing this rewriter, " +
+ "skipping to next rewriter: " + e.getMessage());
+ return execution.search(originalQueryObj);
+ }
+
+ // Check if rewriter result is set properly
+ if(rewriterResult==null) {
+ RewriterUtils.error(logger, originalQueryObj, "Rewriter result are not set properly, " +
+ "skipping to next rewriter");
+ return execution.search(originalQueryObj);
+ }
+
+ // Retrieve results from rewriter
+ Boolean rewritten = (Boolean)rewriterResult.get(RewriterConstants.REWRITTEN);
+ String dictKey = (String)rewriterResult.get(RewriterConstants.DICT_KEY);
+
+ if(rewritten==null || dictKey==null) {
+ RewriterUtils.error(logger, originalQueryObj, "Rewriter result are not set properly, " +
+ "skipping to next rewriter");
+ return execution.search(originalQueryObj);
+ }
+
+ // Retrieve results from rewriter
+ rewriteMeta.put(RewriterConstants.REWRITTEN, (rewritten || prevRewritten));
+ rewriteMeta.put(RewriterConstants.DICT_KEY, dictKey);
+
+ // Pass metadata to the next rewriter
+ RewriterUtils.setRewriteMeta(query, rewriteMeta);
+
+ RewriterUtils.log(logger, query, "Final query: " + query.toDetailString());
+
+ return execution.search(query);
+ }
+
+ /**
+ * Perform the main rewrite logic
+ *
+ * @param query Query object from searcher
+ * @param dictKey the key passed from previous rewriter
+ * to be treated as "original query from user"
+ * For example, if previous is misspell rewriter,
+ * it would pass the corrected query as the
+ * "original query from user". For other rewriters which
+ * add variants, abbr, etc to the query, the original
+ * query should be passed as a key. This rewriter could
+ * still choose to ignore this key. This key
+ * is not the rewritten query itself. For example,
+ * if original query is (willl smith) and the
+ * rewritten query is (willl smith) OR (will smith)
+ * the key to be passed could be (will smith)
+ * @return HashMap which contains the key value pairs:<br>
+ * - whether this query has been rewritten by this
+ * rewriter<br>
+ * key: rewritten<br>
+ * value: true or false<br>
+ * - the key to be treated as "original query from user" in next
+ * rewriter downstream, for example, misspell rewriter
+ * would pass the corrected query as the "original query from
+ * user" to the next rewriter. For other rewriters which
+ * add variants, abbr, etc to the query, the original
+ * query should be passed as a key. This key is not necessarily
+ * consumed by the next rewriter. The next rewriter
+ * can still choose to ignore this key.<br>
+ * key: newDictKey<br>
+ * value: new dict key<br>
+ */
+ protected abstract HashMap<String, Object> rewrite(Query query,
+ String dictKey) throws RuntimeException;
+
+ /**
+ * Check whether rewriter should be skipped if
+ * the query has been rewritten by other rewriter
+ *
+ * @return boolean Whether rewriter should be skipped
+ */
+ protected abstract boolean getSkipRewriterIfRewritten();
+
+ /**
+ * Retrieve rewriter name
+ * It should match the name used in query profile
+ *
+ * @return Name of the rewriter
+ */
+ public abstract String getRewriterName();
+
+ /**
+ * Get default FSA dictionary names
+ *
+ * @return Pair of FSA dictionary name and filename
+ */
+ public abstract HashMap<String, String> getDefaultFSAs();
+
+ /**
+ * Get config parameter value set in query profile
+ *
+ * @param query Query object from the searcher
+ * @param paramName parameter to be retrieved
+ * @return parameter value or null if not found
+ */
+ protected String getQPConfig(Query query,
+ String paramName) {
+ return RewriterUtils.getQPConfig(query, getRewriterName(), paramName);
+ }
+
+ /**
+ * Retrieve rewrite from FSA given the original query
+ *
+ * @param query Query object from searcher
+ * @param dictName FSA dictionary name
+ * @param key The original query used to retrieve rewrite
+ * from the dictionary
+ * @return String The retrieved rewrites, null if query
+ * doesn't exist
+ */
+ protected String getRewriteFromFSA(Query query,
+ String dictName,
+ String key) throws RuntimeException {
+ return RewriterUtils.getRewriteFromFSA(query, rewriterDicts, dictName, key);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java
new file mode 100644
index 00000000000..45ce08de9d5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java
@@ -0,0 +1,55 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite;
+
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.vespa.defaults.Defaults;
+
+/**
+ * Contains common constant strings used by rewriters
+ *
+ * @author Karen Sze Wing Lee
+ */
+public class RewriterConstants {
+
+ /** Config flag for addUnitToOriginalQuery */
+ public static final String ORIGINAL_AS_UNIT = "OriginalAsUnit";
+
+ /** Config flag for addUnitEquivToOriginalQuery */
+ public static final String ORIGINAL_AS_UNIT_EQUIV = "OriginalAsUnitEquiv";
+
+ /** Config flag for addRewritesAsEquiv(false) */
+ public static final String REWRITES_AS_EQUIV = "RewritesAsEquiv";
+
+ /** Config flag for addRewritesAsEquiv(true) */
+ public static final String REWRITES_AS_UNIT_EQUIV = "RewritesAsUnitEquiv";
+
+ /** Config flag for addExpansions */
+ public static final String PARTIAL_PHRASE_MATCH = "PartialPhraseMatch";
+
+ /** Config flag for max number of rewrites added per rewriter */
+ public static final String MAX_REWRITES = "MaxRewrites";
+
+ /** Config flag for considering QSS Rewrite in spell correction */
+ public static final String QSS_RW = "QSSRewrite";
+
+ /** Config flag for considering QSS Suggest in spell correction */
+ public static final String QSS_SUGG = "QSSSuggest";
+
+ /** Config flag for expansion index name */
+ public static final String EXPANSION_INDEX = "ExpansionIndex";
+
+ /** Name for market chain retrieval from user param */
+ public static final String REWRITER_CHAIN = "QRWChain";
+
+ /** Name for rewrite metadata retrieval from query properties */
+ public static final CompoundName REWRITE_META = new CompoundName("RewriteMeta");
+
+ /** Name for rewritten field retrieval from query properties */
+ public static final String REWRITTEN = "Rewritten";
+
+ /** Name for new dictionary key field retrieval from query properties */
+ public static final String DICT_KEY = "DictKey";
+
+ /** Default dictionaries dir */
+ public static final String DEFAULT_DICT_DIR = Defaults.getDefaults().vespaHome() + "share/qrw_data/";
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java
new file mode 100644
index 00000000000..0a5110dbd7e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java
@@ -0,0 +1,651 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite;
+
+import java.util.*;
+import java.util.logging.Logger;
+
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.parser.CustomParser;
+import com.yahoo.search.*;
+import com.yahoo.search.query.*;
+import com.yahoo.prelude.query.*;
+import com.yahoo.prelude.querytransform.PhraseMatcher;
+import com.yahoo.prelude.querytransform.PhraseMatcher.Phrase;
+import com.yahoo.search.query.parser.ParserEnvironment;
+import com.yahoo.search.query.parser.ParserFactory;
+
+/**
+ * Contains commonly used rewriter features
+ *
+ * @author Karen Sze Wing Lee
+ */
+public class RewriterFeatures {
+
+ private static final Logger logger = Logger.getLogger(RewriterFeatures.class.getName());
+
+ /**
+ * <p>Add proximity boosting to original query by modifying
+ * the query tree directly</p>
+ * e.g. original Query Tree: (AND aa bb)<br>
+ * if keepOriginalQuery: true<br>
+ * new Query tree: (OR (AND aa bb) "aa bb")<br>
+ * if keepOriginalQuery: false<br>
+ * new Query Tree: "aa bb"<br><br>
+ *
+ * original Query Tree: (OR (AND aa bb) (AND cc dd))<br>
+ * boostingQuery: cc dd<br>
+ * if keepOriginalQuery: true<br>
+ * new Query Tree: (OR (AND aa bb) (AND cc dd) "cc dd")<br>
+ * if keepOriginalQuery: false<br>
+ * new Query Tree: (OR (AND aa bb) "cc dd") <br>
+ *
+ * @param query Query object from searcher
+ * @param boostingQuery query to be boosted
+ * @param keepOriginalQuery whether to keep original unboosted query as equiv
+ * @return Modified Query object, return original query object
+ * on error
+ */
+ public static Query addUnitToOriginalQuery(Query query, String boostingQuery,
+ boolean keepOriginalQuery)
+ throws RuntimeException {
+ RewriterUtils.log(logger, query, "Adding proximity boosting to [" + boostingQuery + "]");
+
+ Model queryModel = query.getModel();
+ QueryTree qTree = queryModel.getQueryTree();
+ Item oldRoot = qTree.getRoot();
+
+ if (oldRoot == null) {
+ RewriterUtils.error(logger, query, "Error retrieving query tree root");
+ throw new RuntimeException("Error retrieving query tree root");
+ }
+
+ // Convert original query to query tree item
+ Item origQueryItem = convertStringToQTree(query, boostingQuery);
+
+ // Boost proximity by phrasing the original query
+ // query tree structure: (AND aa bb)
+ if (oldRoot instanceof AndItem &&
+ oldRoot.equals(origQueryItem)) {
+ PhraseItem phrase = convertAndToPhrase((AndItem)oldRoot);
+
+ if(!keepOriginalQuery) {
+ qTree.setRoot(phrase);
+ } else {
+ OrItem newRoot = new OrItem();
+ newRoot.addItem(oldRoot);
+ newRoot.addItem(phrase);
+ qTree.setRoot(newRoot);
+ queryModel.setType(Query.Type.ADVANCED); //set type=adv
+ }
+ RewriterUtils.log(logger, query, "Added proximity boosting successfully");
+ return query;
+
+ // query tree structure: (OR (AND aa bb) (AND cc dd))
+ } else if (oldRoot instanceof OrItem &&
+ ((OrItem)oldRoot).getItemIndex(origQueryItem)!=-1 &&
+ origQueryItem instanceof AndItem) {
+
+ // Remove original unboosted query
+ if(!keepOriginalQuery)
+ ((OrItem)oldRoot).removeItem(origQueryItem);
+
+ // Check if the tree already contained the phrase item
+ PhraseItem pI = convertAndToPhrase((AndItem)origQueryItem);
+ if(((OrItem)oldRoot).getItemIndex(pI)==-1) {
+ ((OrItem)oldRoot).addItem(convertAndToPhrase((AndItem)origQueryItem));
+ RewriterUtils.log(logger, query, "Added proximity boosting successfully");
+ return query;
+ }
+ }
+ RewriterUtils.log(logger, query, "No proximity boosting added");
+ return query;
+ }
+
+ /**
+ * <p>Add query expansion to the query tree</p>
+ * e.g. origQuery: aa bb<br>
+ * matchingStr: aa bb<br>
+ * rewrite: cc dd, ee ff<br>
+ * if addUnitToRewrites: false<br>
+ * new query tree: (OR (AND aa bb) (AND cc dd) (AND ee ff))<br>
+ * if addUnitToRewrites: true<br>
+ * new query tree: (OR (AND aa bb) "cc dd" "ee ff") <br>
+ *
+ * @param query Query object from searcher
+ * @param matchingStr string used to retrieve the rewrite
+ * @param rewrites The rewrite string retrieved from
+ * dictionary
+ * @param addUnitToRewrites Whether to add unit to rewrites
+ * @param maxNumRewrites Max number of rewrites to be added,
+ * 0 if no limit
+ * @return Modified Query object, return original query object
+ * on error
+ */
+ public static Query addRewritesAsEquiv(Query query, String matchingStr,
+ String rewrites,
+ boolean addUnitToRewrites,
+ int maxNumRewrites) throws RuntimeException {
+ String normalizedQuery = RewriterUtils.getNormalizedOriginalQuery(query);
+
+ RewriterUtils.log(logger, query,
+ "Adding rewrites [" + rewrites +
+ "] to the query [" + normalizedQuery + "]");
+ if (rewrites.equalsIgnoreCase(normalizedQuery) || rewrites.equalsIgnoreCase("n/a")) {
+ RewriterUtils.log(logger, query, "No rewrite added");
+ return query;
+ }
+
+ Model queryModel = query.getModel();
+ QueryTree qTree = queryModel.getQueryTree();
+ Item oldRoot = qTree.getRoot();
+
+ if (oldRoot == null) {
+ RewriterUtils.error(logger, query, "Error retrieving query tree root");
+ throw new RuntimeException("Error retrieving query tree root");
+ }
+
+ StringTokenizer rewrite_list = new StringTokenizer(rewrites, "\t");
+ Item rI = null;
+
+ // Convert matching string to query tree item
+ Item matchingStrItem = convertStringToQTree(query, matchingStr);
+ PhraseItem matchingStrPhraseItem = null;
+ if(matchingStrItem instanceof AndItem) {
+ matchingStrPhraseItem = convertAndToPhrase(((AndItem)matchingStrItem));
+ }
+
+ // Add rewrites as OR item to the query tree
+ // Only should rewrite in this case:
+ // - origQuery: (OR (AND aa bb) (AND cc dd))
+ // - matchingStr: (AND aa bb)
+ // Or in this case:
+ // - origQuery: (AND aa bb)
+ // - matching Str: (AND aa bb)
+ // Should not rewrite in this case:
+ // - origQuery: (OR (AND cc (OR dd (AND aa bb)) ee)
+ // - matchingStr: (AND aa bb)
+ // - for this case, should use getNonOverlappingMatches instead
+ OrItem newRoot;
+ if(oldRoot instanceof OrItem) {
+ if(((OrItem)oldRoot).getItemIndex(matchingStrItem)==-1) {
+ RewriterUtils.log(logger, query, "Whole query matching is used, skipping rewrite");
+ return query;
+ }
+ newRoot = (OrItem)oldRoot;
+ } else if(oldRoot.equals(matchingStrItem) || oldRoot.equals(matchingStrPhraseItem)) {
+ newRoot = new OrItem();
+ newRoot.addItem(oldRoot);
+ } else {
+ RewriterUtils.log(logger, query, "Whole query matching is used, skipping rewrite");
+ return query;
+ }
+ int numRewrites = 0;
+ while(rewrite_list.hasMoreTokens() &&
+ (maxNumRewrites==0 || numRewrites < maxNumRewrites)) {
+ rI = convertStringToQTree(query, rewrite_list.nextToken());
+ if(addUnitToRewrites && rI instanceof AndItem) {
+ rI = convertAndToPhrase((AndItem)rI);
+ }
+ if(newRoot.getItemIndex(rI)==-1) {
+ newRoot.addItem(rI);
+ numRewrites++;
+ } else {
+ RewriterUtils.log(logger, query, "Rewrite already exist, skipping");
+ }
+ }
+ qTree.setRoot(newRoot);
+ queryModel.setType(Query.Type.ADVANCED); //set type=adv
+ RewriterUtils.log(logger, query, "Added rewrite successfully");
+
+ return query;
+ }
+
+ /**
+ * <p>Retrieve the longest, from left to right non overlapping full
+ * phrase substrings in query based on FSA dictionary</p>
+ *
+ * e.g. query: ((modern AND new AND york AND city AND travel) OR travel) AND
+ * ((sunny AND travel AND agency) OR nyc)<br>
+ * dictionary: <br>
+ * mny\tmodern new york<br>
+ * mo\tmodern<br>
+ * modern\tn/a<br>
+ * modern\tnew york\tn/a<br>
+ * new york\tn/a<br>
+ * new york city\tn/a<br>
+ * new york city travel\tn/a<br>
+ * new york company\tn/a<br>
+ * ny\tnew york<br>
+ * nyc\tnew york city\tnew york company<br>
+ * nyct\tnew york city travel<br>
+ * ta\ttravel agency<br>
+ * travel agency\tn/a<br>
+ * return: nyc
+ * @param phraseMatcher PhraseMatcher object loaded with FSA dict
+ * @param query Query object from the searcher
+ * @return Matching phrases
+ */
+ public static Set<PhraseMatcher.Phrase> getNonOverlappingFullPhraseMatches(PhraseMatcher phraseMatcher,
+ Query query)
+ throws RuntimeException {
+ RewriterUtils.log(logger, query, "Retrieving longest non-overlapping full phrase matches");
+ if(phraseMatcher==null)
+ return null;
+
+ Item root = query.getModel().getQueryTree().getRoot();
+ List<PhraseMatcher.Phrase> matches = phraseMatcher.matchPhrases(root);
+ if (matches==null || matches.isEmpty())
+ return null;
+
+ Set<PhraseMatcher.Phrase> resultMatches = new HashSet<>();
+ ListIterator<Phrase> matchesIter = matches.listIterator();
+
+ // Iterate through all matches
+ while(matchesIter.hasNext()) {
+ PhraseMatcher.Phrase phrase = matchesIter.next();
+ RewriterUtils.log(logger, query, "Working on phrase: " + phrase);
+ CompositeItem currOwner = phrase.getOwner();
+
+ // Check if this is full phrase
+ // If phrase is not an AND item, only keep those that are single word
+ // in order to eliminate cases such as (new RANK york) from being treated
+ // as match if only new york but not new or york is in the dictionary
+ if((currOwner!=null &&
+ ((phrase.isComplete() && currOwner instanceof AndItem) ||
+ (phrase.getLength()==1 && currOwner instanceof OrItem) ||
+ (phrase.getLength()==1 && currOwner instanceof RankItem && phrase.getStartIndex()==0))) ||
+ (currOwner==null && phrase.getLength()==1)) {
+ resultMatches.add(phrase);
+ RewriterUtils.log(logger, query, "Keeping phrase: " + phrase);
+ }
+ }
+
+ RewriterUtils.log(logger, query, "Successfully Retrieved longest non-overlapping full phrase matches");
+ return resultMatches;
+ }
+
+
+ /**
+ * <p>Retrieve the longest, from left to right non overlapping partial
+ * phrase substrings in query based on FSA dictionary</p>
+ *
+ * e.g. query: ((modern AND new AND york AND city AND travel) OR travel) AND
+ * ((sunny AND travel AND agency) OR nyc)<br>
+ * dictionary: <br>
+ * mny\tmodern new york<br>
+ * mo\tmodern<br>
+ * modern\tn/a<br>
+ * modern new york\tn/a<br>
+ * new york\tn/a<br>
+ * new york city\tn/a<br>
+ * new york city travel\tn/a<br>
+ * new york company\tn/a<br>
+ * ny\tnew york<br>
+ * nyc\tnew york city\tnew york company<br>
+ * nyct\tnew york city travel<br>
+ * ta\ttravel agency<br>
+ * travel agency\tn/a<br>
+ * return: <br>
+ * modern<br>
+ * new york city travel<br>
+ * travel agency<br>
+ * nyc<br>
+ * @param phraseMatcher PhraseMatcher object loaded with FSA dict
+ * @param query Query object from the searcher
+ * @return Matching phrases
+ */
+ public static Set<PhraseMatcher.Phrase> getNonOverlappingPartialPhraseMatches(PhraseMatcher phraseMatcher,
+ Query query)
+ throws RuntimeException {
+ RewriterUtils.log(logger, query, "Retrieving longest non-overlapping partial phrase matches");
+ if(phraseMatcher==null)
+ return null;
+
+ Item root = query.getModel().getQueryTree().getRoot();
+ List<PhraseMatcher.Phrase> matches = phraseMatcher.matchPhrases(root);
+ if (matches==null || matches.isEmpty())
+ return null;
+
+ Set<PhraseMatcher.Phrase> resultMatches = new HashSet<>();
+ ArrayList<PhraseMatcher.Phrase> phrasesInSubTree = new ArrayList<>();
+ CompositeItem prevOwner = null;
+ ListIterator<PhraseMatcher.Phrase> matchesIter = matches.listIterator();
+
+ // Iterate through all matches
+ while(matchesIter.hasNext()) {
+ PhraseMatcher.Phrase phrase = matchesIter.next();
+ RewriterUtils.log(logger, query, "Working on phrase: " + phrase);
+ CompositeItem currOwner = phrase.getOwner();
+
+ // Check if previous is AND item and this phrase is in a different item
+ // If so, work on the previous set to eliminate overlapping matches
+ if(!phrasesInSubTree.isEmpty() && currOwner!=null &&
+ prevOwner!=null && !currOwner.equals(prevOwner)) {
+ RewriterUtils.log(logger, query, "Previous phrase is in different AND item");
+ List<PhraseMatcher.Phrase> subTreeMatches
+ = getNonOverlappingMatchesInAndItem(phrasesInSubTree, query);
+ if(subTreeMatches==null) {
+ RewriterUtils.error(logger, query, "Error retrieving matches from subtree");
+ throw new RuntimeException("Error retrieving matches from subtree");
+ }
+ resultMatches.addAll(subTreeMatches);
+ phrasesInSubTree.clear();
+ }
+
+ // Check if this is an AND item
+ if(currOwner!=null && currOwner instanceof AndItem) {
+ phrasesInSubTree.add(phrase);
+ // If phrase is not an AND item, only keep those that are single word
+ // in order to eliminate cases such as (new RANK york) from being treated
+ // as match if only new york but not new or york is in the dictionary
+ } else if (phrase.getLength()==1 &&
+ !(currOwner!=null && currOwner instanceof RankItem && phrase.getStartIndex()!=0)) {
+ resultMatches.add(phrase);
+ }
+
+ prevOwner = currOwner;
+ }
+
+ // Check if last item is AND item
+ // If so, work on the previous set to elimate overlapping matches
+ if(!phrasesInSubTree.isEmpty()) {
+ RewriterUtils.log(logger, query, "Last phrase is in AND item");
+ List<PhraseMatcher.Phrase> subTreeMatches
+ = getNonOverlappingMatchesInAndItem(phrasesInSubTree, query);
+ if(subTreeMatches==null) {
+ RewriterUtils.error(logger, query, "Error retrieving matches from subtree");
+ throw new RuntimeException("Error retrieving matches from subtree");
+ }
+ resultMatches.addAll(subTreeMatches);
+ }
+ RewriterUtils.log(logger, query, "Successfully Retrieved longest non-overlapping partial phrase matches");
+ return resultMatches;
+ }
+
+ /**
+ * <p>Retrieve the longest, from left to right non overlapping substrings in
+ * AndItem based on FSA dictionary</p>
+ *
+ * e.g. subtree: (modern AND new AND york AND city AND travel)<br>
+ * dictionary:<br>
+ * mny\tmodern new york<br>
+ * mo\tmodern<br>
+ * modern\tn/a<br>
+ * modern new york\tn/a<br>
+ * new york\tn/a<br>
+ * new york city\tn/a<br>
+ * new york city travel\tn/a<br>
+ * new york company\tn/a<br>
+ * ny\tnew york<br>
+ * nyc\tnew york city\tnew york company<br>
+ * nyct\tnew york city travel<br>
+ * allMatches:<br>
+ * modern<br>
+ * modern new york<br>
+ * new york<br>
+ * new york city<br>
+ * new york city travel<br>
+ * return: <br>
+ * modern<br>
+ * new york city travel<br>
+ * @param allMatches All matches within the subtree
+ * @param query Query object from the searcher
+ * @return Matching phrases
+ */
+ public static List<PhraseMatcher.Phrase> getNonOverlappingMatchesInAndItem(
+ List<PhraseMatcher.Phrase> allMatches,
+ Query query)
+ throws RuntimeException {
+ RewriterUtils.log(logger, query, "Retrieving longest non-overlapping matches in subtree");
+
+ if (allMatches==null || allMatches.isEmpty())
+ return null;
+
+ if(allMatches.size()==1) {
+ RewriterUtils.log(logger, query, "Only one match in subtree");
+ return allMatches;
+ }
+
+ // Phrase are sorted based on length, if both have the
+ // same length, the lefter one ranks higher
+ RewriterUtils.log(logger, query, "Sorting the phrases");
+ PhraseLength phraseLength = new PhraseLength();
+ Collections.sort(allMatches, phraseLength);
+
+ // Create a bitset with length equal to the number of
+ // items in the subtree
+ int numWords = allMatches.get(0).getOwner().getItemCount();
+ BitSet matchPos = new BitSet(numWords);
+
+ // Removing matches that are overlapping with previously selected ones
+ RewriterUtils.log(logger, query, "Removing matches that are overlapping " +
+ "with previously selected ones");
+ ListIterator<Phrase> allMatchesIter = allMatches.listIterator();
+ while(allMatchesIter.hasNext()) {
+ PhraseMatcher.Phrase currMatch = allMatchesIter.next();
+ PhraseMatcher.Phrase.MatchIterator matchIter = currMatch.itemIterator();
+ if(matchIter.hasNext() && matchIter.next().isFilter()) {
+ RewriterUtils.log(logger, query, "Removing filter item" + currMatch);
+ allMatchesIter.remove();
+ continue;
+ }
+
+ BitSet currMatchPos = new BitSet(numWords);
+ currMatchPos.set(currMatch.getStartIndex(),
+ currMatch.getLength()+currMatch.getStartIndex());
+ if(currMatchPos.intersects(matchPos)) {
+ RewriterUtils.log(logger, query, "Removing " + currMatch);
+ allMatchesIter.remove();
+ } else {
+ RewriterUtils.log(logger, query, "Keeping " + currMatch);
+ matchPos.or(currMatchPos);
+ }
+ }
+ return allMatches;
+ }
+
+ /**
+ * <p>Add Expansions to the matching phrases</p>
+ *
+ * e.g. Query: nyc travel agency<br>
+ * matching phrase: nyc\tnew york city\tnew york company
+ * travel agency\tn/a<br>
+ * if expandIndex is not null and removeOriginal is true<br>
+ * New Query: ((new york city) OR ([expandIndex]:new york city)
+ * OR (new york company) OR
+ * ([expandIndex]:new york company)) AND
+ * ((travel agency) OR ([expandIndex]:travel agency))<br>
+ * if expandIndex is null and removeOriginal is true<br>
+ * New Query: ((new york city) OR (new york company)) AND
+ * travel agency<br>
+ * if expandIndex is null and removeOriginal is false<br>
+ * New Query: (nyc OR (new york city) OR (new york company)) AND
+ * travel agency<br>
+ *
+ * @param query Query object from searcher
+ * @param matches Set of longest non-overlapping matches
+ * @param expandIndex Name of expansion index or null if
+ * default index
+ * @param maxNumRewrites Max number of rewrites to be added,
+ * 0 if no limit
+ * @param removeOriginal Whether to remove the original matching phrase
+ * @param addUnitToRewrites Whether to add rewrite as phrase
+ */
+ public static Query addExpansions(Query query, Set<PhraseMatcher.Phrase> matches,
+ String expandIndex, int maxNumRewrites,
+ boolean removeOriginal, boolean addUnitToRewrites)
+ throws RuntimeException {
+
+ if(matches==null) {
+ RewriterUtils.log(logger, query, "No expansions to be added");
+ return query;
+ }
+
+ RewriterUtils.log(logger, query, "Adding expansions to matching phrases");
+ Model queryModel = query.getModel();
+ QueryTree qTree = queryModel.getQueryTree();
+ Iterator<Phrase> matchesIter = matches.iterator();
+ CompositeItem parent = null;
+
+ // Iterate through all matches
+ while(matchesIter.hasNext()) {
+ PhraseMatcher.Phrase match = matchesIter.next();
+ RewriterUtils.log(logger, query, "Working on phrase: " + match);
+
+ // Retrieve expansion phrases
+ String expansionStr = match.getData();
+ if(expansionStr.equalsIgnoreCase("n/a") && expandIndex==null) {
+ continue;
+ }
+ StringTokenizer expansions = new StringTokenizer(expansionStr,"\t");
+
+ // Create this structure for all expansions of this match
+ // (OR (AND expandsion1) indexName:expansion1
+ // (AND expansion2) indexName:expansion2..)
+ OrItem expansionGrp = new OrItem();
+ int numRewrites = 0;
+ String matchStr = convertMatchToString(match);
+ while(expansions.hasMoreTokens() &&
+ (maxNumRewrites==0 || numRewrites < maxNumRewrites)) {
+ String expansion = expansions.nextToken();
+ RewriterUtils.log(logger, query, "Working on expansion: " + expansion);
+ if(expansion.equalsIgnoreCase("n/a")) {
+ expansion = matchStr;
+ }
+ // (AND expansion) or "expansion"
+ Item expansionItem = convertStringToQTree(query, expansion);
+ if(addUnitToRewrites && expansionItem instanceof AndItem) {
+ expansionItem = convertAndToPhrase((AndItem)expansionItem);
+ }
+ expansionGrp.addItem(expansionItem);
+
+ if(expandIndex!=null) {
+ // indexName:expansion
+ WordItem expansionIndexItem = new WordItem(expansion, expandIndex);
+ expansionGrp.addItem(expansionIndexItem);
+ }
+ numRewrites++;
+ RewriterUtils.log(logger, query, "Adding expansion: " + expansion);
+ }
+
+ if(!removeOriginal) {
+ //(AND original)
+ Item matchItem = convertStringToQTree(query, matchStr);
+ if(expansionGrp.getItemIndex(matchItem)==-1) {
+ expansionGrp.addItem(matchItem);
+ }
+ }
+
+ parent = match.getOwner();
+ int matchIndex = match.getStartIndex();
+ if(parent!=null) {
+ // Remove matching phrase from original query
+ for(int i=0; i<match.getLength(); i++) {
+ parent.removeItem(matchIndex);
+ }
+ // Adding back expansions
+ parent.addItem(matchIndex, expansionGrp);
+ } else {
+ RewriterUtils.log(logger, query, "Single root item");
+ // If there's no parent, i.e. single root item
+ qTree.setRoot(expansionGrp);
+ break;
+ }
+ }
+
+ // Not root single item
+ if(parent!=null) {
+ // Cleaning up the query after rewrite to remove redundant tags
+ // e.g. (AND (OR (AND a b) c)) => (OR (AND a b) c)
+ String cleanupError = QueryCanonicalizer.canonicalize(qTree);
+ if(cleanupError!=null) {
+ RewriterUtils.error(logger, query, "Error canonicalizing query tree");
+ throw new RuntimeException("Error canonicalizing query tree");
+ }
+ }
+ queryModel.setType(Query.Type.ADVANCED); //set type=adv
+ RewriterUtils.log(logger, query, "Successfully added expansions to matching phrases");
+ return query;
+ }
+
+ /**
+ * Convert Match to String
+ *
+ * @param phrase Match from PhraseMatcher
+ * @return String format of the phrase
+ */
+ public static String convertMatchToString(PhraseMatcher.Phrase phrase) {
+ StringBuilder buffer = new StringBuilder();
+ for (Iterator<Item> i = phrase.itemIterator(); i.hasNext();) {
+ buffer.append(i.next().toString());
+ if (i.hasNext()) {
+ buffer.append(" ");
+ }
+ }
+ return buffer.toString();
+ }
+
+ /**
+ * Convert String to query tree
+ *
+ * @param stringToParse The string to be converted to a
+ * query tree
+ * @param query Query object from searcher
+ * @return Item The resulting query tree
+ */
+ static Item convertStringToQTree(Query query, String stringToParse) {
+ RewriterUtils.log(logger, query, "Converting string [" + stringToParse + "] to query tree");
+ if(stringToParse==null) {
+ return new NullItem();
+ }
+ Model model = query.getModel();
+ CustomParser parser = (CustomParser) ParserFactory.newInstance(model.getType(),
+ ParserEnvironment.fromExecutionContext(query.getModel().getExecution().context()));
+ IndexFacts indexFacts = new IndexFacts();
+ Item item = parser.parse(stringToParse, null, model.getParsingLanguage(),
+ indexFacts.newSession(model.getSources(), model.getRestrict()),
+ model.getDefaultIndex());
+ RewriterUtils.log(logger, query, "Converted string: [" + item.toString() + "]");
+ return item;
+ }
+
+ /**
+ * Convert AndItem to PhraseItem<br>
+ *
+ * e.g. (AND a b) to "a b"
+ * @param andItem query tree to be converted
+ * @return converted PhraseItem
+ */
+ private static PhraseItem convertAndToPhrase(AndItem andItem) {
+ PhraseItem result = new PhraseItem();
+ Iterator<Item> subItems = andItem.getItemIterator();
+ while(subItems.hasNext()) {
+ Item curr = (subItems.next());
+ if(curr instanceof IntItem) {
+ WordItem numItem = new WordItem(((IntItem)curr).stringValue());
+ result.addItem(numItem);
+ } else {
+ result.addItem(curr);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Class for comparing phrase.
+ * A phrase is larger if its length is longer.
+ * If both phrases are of the same length, the lefter one
+ * is considered larger
+ */
+ private static class PhraseLength implements Comparator<PhraseMatcher.Phrase> {
+ public int compare(PhraseMatcher.Phrase phrase1, PhraseMatcher.Phrase phrase2) {
+ if((phrase2.getLength()>phrase1.getLength()) ||
+ (phrase2.getLength()==phrase1.getLength() &&
+ phrase2.getStartIndex()<=phrase1.getStartIndex())) {
+ return 1;
+ } else {
+ return -1;
+ }
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java
new file mode 100644
index 00000000000..26ead8de5e5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java
@@ -0,0 +1,334 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite;
+
+import com.yahoo.fsa.FSA;
+import com.yahoo.log.LogLevel;
+import com.yahoo.search.Query;
+import com.yahoo.search.intent.model.IntentModel;
+import com.yahoo.search.intent.model.InterpretationNode;
+import com.yahoo.text.interpretation.Annotations;
+import com.yahoo.text.interpretation.Modification;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.logging.Logger;
+
+import static com.yahoo.language.LinguisticsCase.toLowerCase;
+
+/**
+ * Contains common utilities used by rewriters
+ *
+ * @author Karen Sze Wing Lee
+ */
+public class RewriterUtils {
+
+ private static final Logger utilsLogger = Logger.getLogger(RewriterUtils.class.getName());
+
+ // Tracelevel for debug log of this rewriter
+ private static final int TRACELEVEL = 3;
+
+ /**
+ * Load FSA from file
+ *
+ * @param file FSA dictionary file object
+ * @param query Query object from the searcher, could be null if not available
+ * @return FSA The FSA object for the input file path
+ */
+ public static FSA loadFSA(File file, Query query) throws IOException {
+ log(utilsLogger, query, "Loading FSA file");
+ String filePath = null;
+
+ try {
+ filePath = file.getAbsolutePath();
+ } catch (SecurityException e1) {
+ error(utilsLogger, query, "No read access for the FSA file");
+ throw new IOException("No read access for the FSA file");
+ }
+
+ FSA fsa = loadFSA(filePath, query);
+
+ return fsa;
+ }
+
+ /**
+ * Load FSA from file
+ *
+ * @param filename FSA dictionary file path
+ * @param query Query object from the searcher, could be null if not available
+ * @return FSA The FSA object for the input file path
+ */
+ public static FSA loadFSA(String filename, Query query) throws IOException {
+ log(utilsLogger, query, "Loading FSA file from: " + filename);
+
+ if(!new File(filename).exists()) {
+ error(utilsLogger, query, "File does not exist : " + filename);
+ throw new IOException("File does not exist : " + filename);
+ }
+
+ FSA fsa;
+ try {
+ fsa = new FSA(filename);
+ } catch (RuntimeException e) {
+ error(utilsLogger, query, "Invalid FSA file");
+ throw new IOException("Invalid FSA file");
+ }
+
+ if (!fsa.isOk()) {
+ error(utilsLogger, query, "Unable to load FSA file from : " + filename);
+ throw new IOException("Not able to load FSA file from : " + filename);
+ }
+ log(utilsLogger, query, "Loaded FSA successfully from file : " + filename);
+ return fsa;
+ }
+
+ /**
+ * Retrieve rewrite from FSA given the original query
+ *
+ * @param query Query object from searcher
+ * @param dictName FSA dictionary name
+ * @param rewriterDicts list of rewriter dictionaries
+ * It has the following format:
+ * HashMap&lt;dictionary name, FSA&gt;
+ * @param key The original query used to retrieve rewrite
+ * from the dictionary
+ * @return String The retrieved rewrites, null if query
+ * doesn't exist
+ */
+ public static String getRewriteFromFSA(Query query,
+ HashMap<String, Object> rewriterDicts,
+ String dictName,
+ String key) throws RuntimeException {
+ if(rewriterDicts==null) {
+ error(utilsLogger, query, "HashMap containing rewriter dicts is null");
+ throw new RuntimeException("HashMap containing rewriter dicts is null");
+ }
+
+ FSA fsa = (FSA)rewriterDicts.get(dictName);
+
+ if(fsa==null) {
+ error(utilsLogger, query, "Error retrieving FSA dictionary: " + dictName);
+ throw new RuntimeException("Error retrieving FSA dictionary: " + dictName);
+ }
+
+ String result = null;
+ result = fsa.lookup(key);
+ log(utilsLogger, query, "Retrieved rewrite: " + result);
+
+ return result;
+ }
+
+ /**
+ * Get config parameter value set in query profile
+ *
+ * @param query Query object from the searcher
+ * @param rewriterName Name of the rewriter
+ * @param paramName parameter to be retrieved
+ * @return parameter value or null if not found
+ */
+ public static String getQPConfig(Query query,
+ String rewriterName,
+ String paramName) {
+ log(utilsLogger, query, "Retrieving config parameter value of: " +
+ rewriterName + "." + paramName);
+
+ return getUserParam(query, rewriterName + "." + paramName);
+ }
+
+ /**
+ * Get rewriter chain value
+ *
+ * @param query Query object from the searcher
+ * @return parameter value or null if not found
+ */
+ public static String getRewriterChain(Query query) {
+ log(utilsLogger, query, "Retrieving rewriter chain value: " +
+ RewriterConstants.REWRITER_CHAIN);
+
+ return getUserParam(query, RewriterConstants.REWRITER_CHAIN);
+ }
+
+ /**
+ * Get user param value
+ *
+ * @param query Query object from the searcher
+ * @param paramName parameter to be retrieved
+ *
+ * @return parameter value or null if not found
+ */
+ public static String getUserParam(Query query, String paramName) {
+ log(utilsLogger, query, "Retrieving user param value: " + paramName);
+
+ if(paramName==null) {
+ error(utilsLogger, query, "Parameter name is null");
+ return null;
+ }
+
+ String paramValue = null;
+ paramValue = query.properties().getString(paramName);
+ log(utilsLogger, query, "Param value retrieved is: " + paramValue);
+
+ return paramValue;
+ }
+
+ /**
+ * Retrieve metadata passed by previous rewriter
+ * from query properties
+ * Initialize values if this is the first rewriter
+ *
+ * @param query Query object from the searcher
+ * @return hashmap containing the metadata
+ */
+ public static HashMap<String, Object> getRewriteMeta(Query query) {
+ log(utilsLogger, query, "Retrieving metadata passed by previous rewriter");
+
+ @SuppressWarnings("unchecked")
+ HashMap<String, Object> rewriteMeta = (HashMap<String, Object>) query
+ .properties().get(RewriterConstants.REWRITE_META);
+
+ if(rewriteMeta==null) {
+ log(utilsLogger, query, "No metadata available from previous rewriter");
+ rewriteMeta = new HashMap<>();
+ rewriteMeta.put(RewriterConstants.REWRITTEN, false);
+ rewriteMeta.put(RewriterConstants.DICT_KEY, getNormalizedOriginalQuery(query));
+ } else {
+ if((Boolean)rewriteMeta.get(RewriterConstants.REWRITTEN)) {
+ log(utilsLogger, query, "Query has been rewritten by previous rewriters");
+ } else {
+ log(utilsLogger, query, "Query has not been rewritten by previous rewriters");
+ }
+ log(utilsLogger, query, "Dict key passed by previous rewriter: " +
+ rewriteMeta.get(RewriterConstants.DICT_KEY));
+ }
+
+ return rewriteMeta;
+ }
+
+ /**
+ * Pass metadata to the next rewriter through query properties
+ *
+ * @param query Query object from the searcher
+ * @param metadata HashMap containing the metadata
+ */
+ public static void setRewriteMeta(Query query, HashMap<String, Object> metadata) {
+ log(utilsLogger, query, "Passing metadata to the next rewriter");
+
+ query.properties().set(RewriterConstants.REWRITE_META, metadata);
+ log(utilsLogger, query, "Successfully passed metadata to the next rewriter");
+ }
+
+
+ /**
+ * Retrieve spell corrected query with highest score from QLAS
+ *
+ * @param query Query object from the searcher
+ * @param qss_rw Whether to consider qss_rw modification
+ * @param qss_sugg Whether ot consider qss_sugg modification
+ * @return Spell corrected query or null if not found
+ */
+ public static String getSpellCorrected(Query query,
+ boolean qss_rw,
+ boolean qss_sugg)
+ throws RuntimeException {
+ log(utilsLogger, query, "Retrieving spell corrected query");
+
+ // Retrieve Intent Model
+ IntentModel intentModel = IntentModel.getFrom(query);
+ if(intentModel==null) {
+ error(utilsLogger, query, "Unable to retrieve intent model");
+ throw new RuntimeException("Not able to retrieve intent model");
+ }
+
+ double max_score = 0;
+ String spellCorrected = null;
+
+ // Iterate through all interpretations to get a spell corrected
+ // query with highest score
+ for (InterpretationNode interpretationNode : intentModel.children()) {
+ Modification modification = interpretationNode.getInterpretation()
+ .getModification();
+ Annotations annotations = modification.getAnnotation();
+ Double score = annotations.getDouble("score");
+
+ // Check if it's higher than the max score
+ if(score!=null && score>max_score) {
+ Boolean isQSSRewrite = annotations.getBoolean("qss_rw");
+ Boolean isQSSSuggest = annotations.getBoolean("qss_sugg");
+
+ // Check if it's qss_rw or qss_sugg
+ if((qss_rw && isQSSRewrite!=null && isQSSRewrite) ||
+ (qss_sugg && isQSSSuggest!=null && isQSSSuggest)) {
+ max_score = score;
+ spellCorrected = modification.getText();
+ }
+ }
+ }
+
+ if(spellCorrected!=null) {
+ log(utilsLogger, query, "Successfully retrieved spell corrected query: " +
+ spellCorrected);
+ } else {
+ log(utilsLogger, query, "No spell corrected query is retrieved");
+ }
+
+ return spellCorrected;
+ }
+
+ /**
+ * Retrieve normalized original query from query object
+ *
+ * @param query Query object from searcher
+ * @return normalized query
+ */
+ public static String getNormalizedOriginalQuery(Query query) {
+ return toLowerCase(query.getModel().getQueryString()).trim();
+ }
+
+ /**
+ * Log message
+ *
+ * @param logger Logger used for this msg
+ * @param msg Log message
+ */
+ public static void log(Logger logger, String msg) {
+ logger.log(LogLevel.DEBUG, logger.getName() + ": " + msg);
+ }
+
+ /**
+ * Log message
+ *
+ * @param logger Logger used for this msg
+ * @param query Query object from searcher
+ * @param msg Log message
+ */
+ public static void log(Logger logger, Query query, String msg) {
+ if(query!=null) {
+ query.trace(logger.getName() + ": " + msg, true, TRACELEVEL);
+ }
+ logger.log(LogLevel.DEBUG, logger.getName() + ": " + msg);
+ }
+
+ /**
+ * Print error message
+ *
+ * @param logger Logger used for this msg
+ * @param msg Error message
+ */
+ public static void error(Logger logger, String msg) {
+ logger.severe(logger.getName() + ": " + msg);
+ }
+
+ /**
+ * Print error message
+ *
+ * @param logger Logger used for this msg
+ * @param query Query object from searcher
+ * @param msg Error message
+ */
+ public static void error(Logger logger, Query query, String msg) {
+ if(query!=null) {
+ query.trace(logger.getName() + ": " + msg, true, TRACELEVEL);
+ }
+ logger.severe(logger.getName() + ": " + msg);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java
new file mode 100644
index 00000000000..589696c4e77
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java
@@ -0,0 +1,74 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite;
+
+import com.yahoo.component.chain.Chain;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.search.*;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.component.ComponentId;
+
+import java.util.logging.Logger;
+
+/**
+ * Execute rewriter search chain specified by the user.
+ * It's inteneded to be used for executing rewriter search chains
+ * for different markets.
+ *
+ * @author Karen Sze Wing Lee
+ */
+@Provides("SearchChainDispatcher")
+@After("QLAS")
+public class SearchChainDispatcherSearcher extends Searcher {
+
+ protected final Logger logger = Logger.getLogger(SearchChainDispatcherSearcher.class.getName());
+
+ /**
+ * Constructor for this searcher
+ * @param id Component ID (see vespa's search container doc for more detail)
+ */
+ public SearchChainDispatcherSearcher(ComponentId id) {
+ super(id);
+ }
+
+ /**
+ * Constructor for unit test
+ */
+ public SearchChainDispatcherSearcher() {
+ }
+
+ /**
+ * Execute another search chain specified by the user<br>
+ * - Retrieve search chain specified by the user through
+ * param<br>
+ * - Execute specified search chain if exist
+ */
+ public @Override Result search(Query query, Execution execution) {
+ RewriterUtils.log(logger, query, "Entering SearchChainDispatcherSearcher");
+
+ // Retrieve search chain specified by user through REWRITER_CHAIN
+ String rewriterChain = RewriterUtils.getRewriterChain(query);
+
+ // Skipping to next searcher if no rewriter chain is specified
+ if(rewriterChain==null || rewriterChain.equals("")) {
+ RewriterUtils.log(logger, query, "No rewriter chain is specified, " +
+ "skipping to the next searcher");
+ return execution.search(query);
+ }
+
+ // Execute rewriter search chain
+ RewriterUtils.log(logger, query, "Redirecting to chain " + rewriterChain);
+ Chain<Searcher> myChain = execution.searchChainRegistry().getChain(rewriterChain);
+ if(myChain==null) {
+ RewriterUtils.log(logger, query, "Invalid search chain specified, " +
+ "skipping to the next searcher");
+ return execution.search(query);
+ }
+ new Execution(myChain, execution.context()).search(query);
+ RewriterUtils.log(logger, query, "Finish executing search chain " + rewriterChain);
+
+ // Continue down the chain ignoring the result from REWRITER_CHAIN
+ // since the rewriters only modify the query but not the result
+ return execution.search(query);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java
new file mode 100644
index 00000000000..c435ed45623
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.rewrite;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java
new file mode 100644
index 00000000000..3d57675c4ab
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java
@@ -0,0 +1,213 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite.rewriters;
+
+import java.io.*;
+import java.util.*;
+import java.util.logging.Logger;
+
+import com.google.inject.Inject;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.fsa.FSA;
+import com.yahoo.search.query.rewrite.*;
+import com.yahoo.search.*;
+import com.yahoo.component.ComponentId;
+import com.yahoo.filedistribution.fileacquirer.FileAcquirer;
+import com.yahoo.search.query.rewrite.RewritesConfig;
+import com.yahoo.prelude.querytransform.PhraseMatcher;
+
+/**
+ * This rewriter would add rewrites to entities (e.g abbreviation, synonym, etc)<br>
+ * to boost precision
+ * - FSA dict: [normalized original query]\t[rewrite 1]\t[rewrite 2]\t[etc]<br>
+ * - Features:<br>
+ * RewritesAsUnitEquiv flag: add proximity boosted rewrites<br>
+ * PartialPhraseMatch flag: whether to match whole phrase or partial phrase<br>
+ * MaxRewrites flag: the maximum number of rewrites to be added<br>
+ *
+ * @author Karen Sze Wing Lee
+ */
+@Provides("GenericExpansionRewriter")
+public class GenericExpansionRewriter extends QueryRewriteSearcher {
+
+ // Flag for skipping this rewriter if the query has been rewritten
+ private final boolean SKIP_REWRITER_IF_REWRITTEN = false;
+
+ // Name of the rewriter
+ public static final String REWRITER_NAME = "GenericExpansionRewriter";
+
+ // Generic expansion dictionary name
+ public static final String GENERIC_EXPAND_DICT = "GenericExpansion";
+
+ // Default generic expansion dictionary file name
+ public static final String GENERIC_EXPAND_DICT_FILENAME = "GenericExpansionRewriter.fsa";
+
+ // PhraseMatcher created from FSA dict
+ private PhraseMatcher phraseMatcher;
+
+ private Logger logger;
+
+
+ /**
+ * Constructor for GenericExpansionRewriter.
+ * Load configs using default format
+ */
+ @Inject
+ public GenericExpansionRewriter(ComponentId id,
+ FileAcquirer fileAcquirer,
+ RewritesConfig config) {
+ super(id, fileAcquirer, config);
+ }
+
+ /**
+ * Constructor for GenericExpansionRewriter unit test.
+ * Load configs using default format
+ */
+ public GenericExpansionRewriter(RewritesConfig config,
+ HashMap<String, File> fileList) {
+ super(config, fileList);
+ }
+
+ /**
+ * Instance creation time config loading besides FSA.
+ * Create PhraseMatcher from FSA dict
+ */
+ public boolean configure(FileAcquirer fileAcquirer,
+ RewritesConfig config,
+ HashMap<String, File> fileList) {
+ logger = Logger.getLogger(GenericExpansionRewriter.class.getName());
+ FSA fsa = (FSA)rewriterDicts.get(GENERIC_EXPAND_DICT);
+ if(fsa==null) {
+ RewriterUtils.error(logger, "Error retrieving FSA dictionary: " +
+ GENERIC_EXPAND_DICT);
+ return false;
+ }
+ // Create Phrase Matcher
+ RewriterUtils.log(logger, "Creating PhraseMatcher");
+ try {
+ phraseMatcher = new PhraseMatcher(fsa, false);
+ } catch (IllegalArgumentException e) {
+ RewriterUtils.error(logger, "Error creating phrase matcher");
+ return false;
+ }
+
+ // Match single word as well
+ phraseMatcher.setMatchSingleItems(true);
+
+ // Return all matches instead of only the longest match
+ phraseMatcher.setMatchAll(true);
+
+ return true;
+ }
+
+ /**
+ * Main logic of rewriter<br>
+ * - Retrieve rewrites from FSA dict<br>
+ * - rewrite query using features that are enabled by user
+ */
+ public HashMap<String, Object> rewrite(Query query,
+ String dictKey) throws RuntimeException {
+
+ Boolean rewritten = false;
+
+ // Pass the original dict key to the next rewriter
+ HashMap<String, Object> result = new HashMap<>();
+ result.put(RewriterConstants.REWRITTEN, rewritten);
+ result.put(RewriterConstants.DICT_KEY, dictKey);
+
+ RewriterUtils.log(logger, query,
+ "In GenericExpansionRewriter, query used for dict retrieval=[" + dictKey + "]");
+
+ // Retrieve flags for choosing between whole query match
+ // or partial query match
+ String partialPhraseMatch = getQPConfig(query, RewriterConstants.PARTIAL_PHRASE_MATCH);
+
+ if(partialPhraseMatch==null) {
+ RewriterUtils.error(logger, query, "Required param " + RewriterConstants.PARTIAL_PHRASE_MATCH +
+ " is not set, skipping rewriter");
+ throw new RuntimeException("Required param " + RewriterConstants.PARTIAL_PHRASE_MATCH +
+ " is not set, skipping rewriter");
+ }
+
+ // Retrieve max number of rewrites allowed
+ int maxNumRewrites = 0;
+ String maxNumRewritesStr = getQPConfig(query, RewriterConstants.MAX_REWRITES);
+ if(maxNumRewritesStr!=null) {
+ maxNumRewrites = Integer.parseInt(maxNumRewritesStr);
+ RewriterUtils.log(logger, query,
+ "Limiting max number of rewrites to: " + maxNumRewrites);
+ } else {
+ RewriterUtils.log(logger, query, "No limit on number of rewrites");
+ }
+
+ // Retrieve flags for choosing whether to add
+ // the rewrites as phrase, default to false
+ String rewritesAsUnitEquiv = getQPConfig(query, RewriterConstants.REWRITES_AS_UNIT_EQUIV);
+ if(rewritesAsUnitEquiv==null) {
+ rewritesAsUnitEquiv = "false";
+ }
+
+ Set<PhraseMatcher.Phrase> matches;
+
+ // Partial Phrase Matching
+ if(partialPhraseMatch.equalsIgnoreCase("true")) {
+ RewriterUtils.log(logger, query, "Partial phrase matching");
+
+ // Retrieve longest non overlapping matches
+ matches = RewriterFeatures.getNonOverlappingPartialPhraseMatches(phraseMatcher, query);
+
+ // Full Phrase Matching if set to anything else
+ } else {
+ RewriterUtils.log(logger, query, "Full phrase matching");
+
+ // Retrieve longest non overlapping matches
+ matches = RewriterFeatures.getNonOverlappingFullPhraseMatches(phraseMatcher, query);
+ }
+
+ if(matches==null) {
+ return result;
+ }
+
+ // Add expansions to the query
+ query = RewriterFeatures.addExpansions(query, matches, null, maxNumRewrites, false,
+ rewritesAsUnitEquiv.equalsIgnoreCase("true"));
+
+ rewritten = true;
+
+ RewriterUtils.log(logger, query, "GenericExpansionRewriter final query: " + query.toDetailString());
+
+ result.put(RewriterConstants.REWRITTEN, rewritten);
+
+ return result;
+ }
+
+ /**
+ * Get the flag which specifies whether this rewriter
+ * should be skipped if the query has been rewritten
+ *
+ * @return true if rewriter should be skipped, false
+ * otherwise
+ */
+ public boolean getSkipRewriterIfRewritten() {
+ return SKIP_REWRITER_IF_REWRITTEN;
+ }
+
+ /**
+ * Get the name of the rewriter
+ *
+ * @return Name of the rewriter
+ */
+ public String getRewriterName() {
+ return REWRITER_NAME;
+ }
+
+ /**
+ * Get default FSA dictionary names
+ *
+ * @return Pair of FSA dictionary name and filename
+ */
+ public HashMap<String, String> getDefaultFSAs() {
+ HashMap<String, String> defaultDicts = new HashMap<>();
+ defaultDicts.put(GENERIC_EXPAND_DICT, GENERIC_EXPAND_DICT_FILENAME);
+ return defaultDicts;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java
new file mode 100644
index 00000000000..a1b46926cbd
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java
@@ -0,0 +1,151 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite.rewriters;
+
+import java.io.*;
+import java.util.*;
+import java.util.logging.Logger;
+
+import com.google.inject.Inject;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.search.query.rewrite.*;
+import com.yahoo.search.*;
+import com.yahoo.component.ComponentId;
+import com.yahoo.filedistribution.fileacquirer.FileAcquirer;
+import com.yahoo.search.query.rewrite.RewritesConfig;
+
+/**
+ * This rewriter would retrieve spell corrected query from QLAS and
+ * add it to the original query tree as equiv<br>
+ * - Features:<br>
+ * RewritesAsEquiv flag: add rewrites to original query as equiv
+ *
+ * @author Karen Sze Wing Lee
+ */
+@After("QLAS")
+@Provides("MisspellRewriter")
+public class MisspellRewriter extends QueryRewriteSearcher {
+
+ // Flag for skipping this rewriter if the query has been rewritten
+ private final boolean SKIP_REWRITER_IF_REWRITTEN = false;
+
+ // Name of the rewriter
+ public static final String REWRITER_NAME = "MisspellRewriter";
+
+ private Logger logger = Logger.getLogger(MisspellRewriter.class.getName());
+
+ /**
+ * Constructor for MisspellRewriter
+ */
+ @Inject
+ public MisspellRewriter(ComponentId id) {
+ super(id);
+ }
+
+ /**
+ * Constructor for MisspellRewriter unit test
+ */
+ public MisspellRewriter() {
+ super();
+ }
+
+ /**
+ * Instance creation time config loading besides FSA.
+ * Empty for this rewriter
+ */
+ public boolean configure(FileAcquirer fileAcquirer,
+ RewritesConfig config,
+ HashMap<String, File> fileList) {
+ return true;
+ }
+
+ /**
+ * Main logic of rewriter<br>
+ * - Retrieve spell corrected query from QLAS<br>
+ * - Add spell corrected query as equiv
+ */
+ public HashMap<String, Object> rewrite(Query query,
+ String dictKey) throws RuntimeException {
+
+ Boolean rewritten = false;
+
+ HashMap<String, Object> result = new HashMap<>();
+ result.put(RewriterConstants.REWRITTEN, rewritten);
+ result.put(RewriterConstants.DICT_KEY, dictKey);
+
+ RewriterUtils.log(logger, query,
+ "In MisspellRewriter");
+
+ // Retrieve flags for enabling the features
+ String qssRw = getQPConfig(query, RewriterConstants.QSS_RW);
+ String qssSugg = getQPConfig(query, RewriterConstants.QSS_SUGG);
+
+ boolean isQSSRw = false;
+ boolean isQSSSugg = false;
+
+ if(qssRw!=null) {
+ isQSSRw = qssRw.equalsIgnoreCase("true");
+ }
+ if(qssSugg!=null) {
+ isQSSSugg = qssSugg.equalsIgnoreCase("true");
+ }
+
+ // Rewrite is not enabled
+ if(!isQSSRw && !isQSSSugg) {
+ return result;
+ }
+
+ // Retrieve spell corrected query from QLAS
+ String rewrites = RewriterUtils.getSpellCorrected(query, isQSSRw, isQSSSugg);
+
+ // No rewrites
+ if(rewrites==null) {
+ RewriterUtils.log(logger, query, "No rewrite is retrieved");
+ return result;
+ } else {
+ RewriterUtils.log(logger, query, "Retrieved spell corrected query: " +
+ rewrites);
+ }
+
+ // Adding rewrite to the query tree
+ query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, false, 0);
+
+ rewritten = true;
+ RewriterUtils.log(logger, query, "MisspellRewriter final query: " +
+ query.toDetailString());
+
+ result.put(RewriterConstants.REWRITTEN, rewritten);
+ result.put(RewriterConstants.DICT_KEY, rewrites);
+
+ return result;
+ }
+
+ /**
+ * Get the flag which specifies whether this rewriter
+ * should be skipped if the query has been rewritten
+ *
+ * @return true if rewriter should be skipped, false
+ * otherwise
+ */
+ public boolean getSkipRewriterIfRewritten() {
+ return SKIP_REWRITER_IF_REWRITTEN;
+ }
+
+ /**
+ * Get the name of the rewriter
+ *
+ * @return Name of the rewriter
+ */
+ public String getRewriterName() {
+ return REWRITER_NAME;
+ }
+
+ /**
+ * Get default FSA dictionary names
+ *
+ * @return Pair of FSA dictionary name and filename
+ */
+ public HashMap<String, String> getDefaultFSAs() {
+ return null;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java
new file mode 100644
index 00000000000..5ecf7893c63
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java
@@ -0,0 +1,194 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.rewrite.rewriters;
+
+import java.io.*;
+import java.util.*;
+import java.util.logging.Logger;
+
+import com.google.inject.Inject;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.search.query.rewrite.*;
+import com.yahoo.search.*;
+import com.yahoo.component.ComponentId;
+import com.yahoo.filedistribution.fileacquirer.FileAcquirer;
+import com.yahoo.search.query.rewrite.RewritesConfig;
+
+/**
+ * This rewriter would add rewrites to name entities to boost precision<br>
+ * - FSA dict: [normalized original query]\t[rewrite 1]\t[rewrite 2]\t[etc]<br>
+ * - Features:<br>
+ * OriginalAsUnit flag: add proximity boosting to original query<br>
+ * RewritesAsUnitEquiv flag: add proximity boosted rewrites to original query<br>
+ * RewritesAsEquiv flag: add rewrites to original query<br>
+ *
+ * @author Karen Sze Wing Lee
+ */
+@Provides("NameRewriter")
+public class NameRewriter extends QueryRewriteSearcher {
+
+ // Flag for skipping this rewriter if the query has been rewritten
+ private final boolean SKIP_REWRITER_IF_REWRITTEN = false;
+
+ // Name of the rewriter
+ public static final String REWRITER_NAME = "NameRewriter";
+
+ // Name entity expansion dictionary name
+ public static final String NAME_ENTITY_EXPAND_DICT = "NameEntityExpansion";
+
+ // Default Name entity expansion dictionary file name
+ public static final String NAME_ENTITY_EXPAND_DICT_FILENAME = "NameRewriter.fsa";
+
+ private Logger logger;
+
+ /**
+ * Constructor for NameRewriter<br>
+ * Load configs using default format
+ */
+ @Inject
+ public NameRewriter(ComponentId id,
+ FileAcquirer fileAcquirer,
+ RewritesConfig config) {
+ super(id, fileAcquirer, config);
+ }
+
+ /**
+ * Constructor for NameRewriter unit test<br>
+ * Load configs using default format
+ */
+ public NameRewriter(RewritesConfig config,
+ HashMap<String, File> fileList) {
+ super(config, fileList);
+ }
+
+ /**
+ * Instance creation time config loading besides FSA<br>
+ * Empty for this rewriter
+ */
+ public boolean configure(FileAcquirer fileAcquirer,
+ RewritesConfig config,
+ HashMap<String, File> fileList) {
+ logger = Logger.getLogger(NameRewriter.class.getName());
+ return true;
+ }
+
+ /**
+ * Main logic of rewriter<br>
+ * - Retrieve rewrites from FSA dict<br>
+ * - rewrite query using features that are enabled by user
+ */
+ public HashMap<String, Object> rewrite(Query query,
+ String dictKey) throws RuntimeException {
+
+ Boolean rewritten = false;
+
+ // Pass the original dict key to the next rewriter
+ HashMap<String, Object> result = new HashMap<>();
+ result.put(RewriterConstants.REWRITTEN, rewritten);
+ result.put(RewriterConstants.DICT_KEY, dictKey);
+
+ RewriterUtils.log(logger, query,
+ "In NameRewriter, query used for dict retrieval=[" + dictKey + "]");
+
+ // Retrieve rewrite from FSA dict using normalized query
+ String rewrites = super.getRewriteFromFSA(query, NAME_ENTITY_EXPAND_DICT, dictKey);
+ RewriterUtils.log(logger, query, "Retrieved rewrites: " + rewrites);
+
+ // No rewrites
+ if(rewrites==null) {
+ RewriterUtils.log(logger, query, "No rewrite is retrieved");
+ return result;
+ }
+
+ // Retrieve max number of rewrites allowed
+ int maxNumRewrites = 0;
+ String maxNumRewritesStr = getQPConfig(query, RewriterConstants.MAX_REWRITES);
+ if(maxNumRewritesStr!=null) {
+ maxNumRewrites = Integer.parseInt(maxNumRewritesStr);
+ RewriterUtils.log(logger, query,
+ "Limiting max number of rewrites to: " + maxNumRewrites);
+ } else {
+ RewriterUtils.log(logger, query, "No limit on number of rewrites");
+ }
+
+ // Retrieve flags for enabling the features
+ String originalAsUnit = getQPConfig(query, RewriterConstants.ORIGINAL_AS_UNIT);
+ String originalAsUnitEquiv = getQPConfig(query, RewriterConstants.ORIGINAL_AS_UNIT_EQUIV);
+ String rewritesAsUnitEquiv = getQPConfig(query, RewriterConstants.REWRITES_AS_UNIT_EQUIV);
+ String rewritesAsEquiv = getQPConfig(query, RewriterConstants.REWRITES_AS_EQUIV);
+
+ // Add proximity boosting to original query and keeping
+ // the original query if it's enabled
+ if(originalAsUnitEquiv!=null && originalAsUnitEquiv.equalsIgnoreCase("true")) {
+ RewriterUtils.log(logger, query, "OriginalAsUnitEquiv is enabled");
+ query = RewriterFeatures.addUnitToOriginalQuery(query, dictKey, true);
+ RewriterUtils.log(logger, query,
+ "Query after OriginalAsUnitEquiv: " + query.toDetailString());
+ rewritten = true;
+
+ // Add proximity boosting to original query
+ // if it's enabled
+ } else if(originalAsUnit!=null && originalAsUnit.equalsIgnoreCase("true")) {
+ RewriterUtils.log(logger, query, "OriginalAsUnit is enabled");
+ query = RewriterFeatures.addUnitToOriginalQuery(query, dictKey, false);
+ RewriterUtils.log(logger, query,
+ "Query after OriginalAsUnit: " + query.toDetailString());
+ rewritten = true;
+ }
+
+ // Add rewrites as unit equiv if it's enabled
+ if(rewritesAsUnitEquiv!=null && rewritesAsUnitEquiv.equalsIgnoreCase("true")) {
+ RewriterUtils.log(logger, query, "RewritesAsUnitEquiv is enabled");
+ //query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, true, maxNumRewrites);
+ query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, true, maxNumRewrites);
+ RewriterUtils.log(logger, query,
+ "Query after RewritesAsUnitEquiv: " + query.toDetailString());
+ rewritten = true;
+
+ // Add rewrites as equiv if it's enabled
+ } else if(rewritesAsEquiv!=null && rewritesAsEquiv.equalsIgnoreCase("true")) {
+ RewriterUtils.log(logger, query, "RewritesAsEquiv is enabled");
+ //query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, false, maxNumRewrites);
+ query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, false, maxNumRewrites);
+ RewriterUtils.log(logger, query,
+ "Query after RewritesAsEquiv: " + query.toDetailString());
+ rewritten = true;
+ }
+
+ RewriterUtils.log(logger, query, "NameRewriter final query: " + query.toDetailString());
+
+ result.put(RewriterConstants.REWRITTEN, rewritten);
+
+ return result;
+ }
+
+ /**
+ * Get the flag which specifies whether this rewriter.
+ * should be skipped if the query has been rewritten
+ *
+ * @return true if rewriter should be skipped, false
+ * otherwise
+ */
+ public boolean getSkipRewriterIfRewritten() {
+ return SKIP_REWRITER_IF_REWRITTEN;
+ }
+
+ /**
+ * Get the name of the rewriter
+ *
+ * @return Name of the rewriter
+ */
+ public String getRewriterName() {
+ return REWRITER_NAME;
+ }
+
+ /**
+ * Get default FSA dictionary names
+ *
+ * @return Pair of FSA dictionary name and filename
+ */
+ public HashMap<String, String> getDefaultFSAs() {
+ HashMap<String, String> defaultDicts = new HashMap<>();
+ defaultDicts.put(NAME_ENTITY_EXPAND_DICT, NAME_ENTITY_EXPAND_DICT_FILENAME);
+ return defaultDicts;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java
new file mode 100644
index 00000000000..bfbb73f661e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.rewrite.rewriters;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java
new file mode 100644
index 00000000000..bac9f2af237
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.search.query.textserialize.item.ItemContext;
+import com.yahoo.search.query.textserialize.item.ItemFormHandler;
+import com.yahoo.search.query.textserialize.parser.ParseException;
+import com.yahoo.search.query.textserialize.parser.Parser;
+import com.yahoo.search.query.textserialize.parser.TokenMgrError;
+import com.yahoo.search.query.textserialize.serializer.QueryTreeSerializer;
+
+import java.io.StringReader;
+
+/**
+ * @author tonytv
+ * Facade
+ * Allows serializing/deserializing a query to the programmatic format.
+ */
+public class TextSerialize {
+ public static Item parse(String serializedQuery) {
+ try {
+ ItemContext context = new ItemContext();
+ Object result = new Parser(new StringReader(serializedQuery.replace("'", "\"")), new ItemFormHandler(), context).start();
+ context.connectItems();
+
+ if (!(result instanceof Item)) {
+ throw new RuntimeException("The serialized query '" + serializedQuery + "' did not evaluate to an Item" +
+ "(type = " + result.getClass() + ")");
+ }
+ return (Item) result;
+ } catch (ParseException e) {
+ throw new RuntimeException(e);
+ } catch (TokenMgrError e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static String serialize(Item item) {
+ return new QueryTreeSerializer().serialize(item);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java
new file mode 100644
index 00000000000..c4e54ca748d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java
@@ -0,0 +1,54 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.NotItem;
+
+import java.util.List;
+
+import static com.yahoo.search.query.textserialize.item.ListUtil.butFirst;
+import static com.yahoo.search.query.textserialize.item.ListUtil.first;
+
+/**
+ * @author tonytv
+ */
+public class AndNotRestConverter extends CompositeConverter<NotItem> {
+ static final String andNotRest = "AND-NOT-REST";
+
+ public AndNotRestConverter() {
+ super(NotItem.class);
+ }
+
+ @Override
+ protected void addChildren(NotItem item, ItemArguments arguments, ItemContext context) {
+ if (firstIsNull(arguments.children)) {
+ addNegativeItems(item, arguments.children);
+ } else {
+ addItems(item, arguments.children);
+ }
+ }
+
+ private void addNegativeItems(NotItem notItem, List<Object> children) {
+ for (Object child: butFirst(children)) {
+ TypeCheck.ensureInstanceOf(child, Item.class);
+ notItem.addNegativeItem((Item) child);
+ }
+ }
+
+ private void addItems(NotItem notItem, List<Object> children) {
+ for (Object child : children) {
+ TypeCheck.ensureInstanceOf(child, Item.class);
+ notItem.addItem((Item) child);
+ }
+ }
+
+
+ private boolean firstIsNull(List<Object> children) {
+ return !children.isEmpty() && first(children) == null;
+ }
+
+ @Override
+ protected String getFormName(Item item) {
+ return andNotRest;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java
new file mode 100644
index 00000000000..7f7c5e48d0a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java
@@ -0,0 +1,66 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.search.query.textserialize.serializer.DispatchForm;
+import com.yahoo.search.query.textserialize.serializer.ItemIdMapper;
+
+import java.util.ListIterator;
+
+/**
+ * @author tonytv
+ */
+public class CompositeConverter<T extends CompositeItem> implements ItemFormConverter {
+ private final Class<T> itemClass;
+
+ public CompositeConverter(Class<T> itemClass) {
+ this.itemClass = itemClass;
+ }
+
+ @Override
+ public Object formToItem(String name, ItemArguments arguments, ItemContext itemContext) {
+ T item = newInstance();
+ addChildren(item, arguments, itemContext);
+ return item;
+ }
+
+ protected void addChildren(T item, ItemArguments arguments, ItemContext itemContext) {
+ for (Object child : arguments.children) {
+ item.addItem(asItem(child));
+ }
+ ItemInitializer.initialize(item, arguments, itemContext);
+ }
+
+ private static Item asItem(Object child) {
+ if (!(child instanceof Item) && child != null) {
+ throw new RuntimeException("Expected query item, but got '" + child.toString() +
+ "' [" + child.getClass().getName() + "]");
+ }
+ return (Item) child;
+ }
+
+ private T newInstance() {
+ try {
+ return itemClass.newInstance();
+ } catch (InstantiationException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper) {
+ CompositeItem compositeItem = (CompositeItem) item;
+
+ DispatchForm form = new DispatchForm(getFormName(item));
+ for (ListIterator<Item> i = compositeItem.getItemIterator(); i.hasNext() ;) {
+ form.addChild(i.next());
+ }
+ ItemInitializer.initializeForm(form, item, itemIdMapper);
+ return form;
+ }
+
+ protected String getFormName(Item item) {
+ return item.getItemType().name();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java
new file mode 100644
index 00000000000..4b68ecfe5a9
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java
@@ -0,0 +1,15 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.ExactstringItem;
+
+/**
+ * @author balder
+ */
+// TODO: balder to fix javadoc
+public class ExactStringConverter extends WordConverter {
+ @Override
+ ExactstringItem newTermItem(String word) {
+ return new ExactstringItem(word);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java
new file mode 100644
index 00000000000..43b96d17773
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.IntItem;
+import com.yahoo.prelude.query.TermItem;
+
+/**
+ * @author tonytv
+ */
+public class IntConverter extends TermConverter {
+ @Override
+ IntItem newTermItem(String word) {
+ return new IntItem(word);
+ }
+
+ @Override
+ protected String getValue(TermItem item) {
+ return ((IntItem)item).getNumber();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java
new file mode 100644
index 00000000000..50cc9c42773
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java
@@ -0,0 +1,26 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static com.yahoo.search.query.textserialize.item.ListUtil.firstInstanceOf;
+
+/**
+ * @author tonytv
+ */
+public class ItemArguments {
+ public final Map<?, ?> properties;
+ public final List<Object> children;
+
+ public ItemArguments(List<Object> arguments) {
+ if (firstInstanceOf(arguments, Map.class)) {
+ properties = (Map<?, ?>) ListUtil.first(arguments);
+ children = ListUtil.rest(arguments);
+ } else {
+ properties = Collections.emptyMap();
+ children = arguments;
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java
new file mode 100644
index 00000000000..fd21b4e02e1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java
@@ -0,0 +1,49 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.TaggableItem;
+
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+/**
+ * @author tonytv
+ */
+public class ItemContext {
+ private class Connectivity {
+ final String id;
+ final double strength;
+
+ public Connectivity(String id, double strength) {
+ this.id = id;
+ this.strength = strength;
+ }
+ }
+
+ private final Map<String, Item> itemById = new HashMap<>();
+ private final Map<TaggableItem, Connectivity> connectivityByItem = new IdentityHashMap<>();
+
+
+ public void setItemId(String id, Item item) {
+ itemById.put(id, item);
+ }
+
+ public void setConnectivity(TaggableItem item, String id, Double strength) {
+ connectivityByItem.put(item, new Connectivity(id, strength));
+ }
+
+ public void connectItems() {
+ for (Map.Entry<TaggableItem, Connectivity> entry : connectivityByItem.entrySet()) {
+ entry.getKey().setConnectivity(getItem(entry.getValue().id), entry.getValue().strength);
+ }
+ }
+
+ private Item getItem(String id) {
+ Item item = itemById.get(id);
+ if (item == null)
+ throw new IllegalArgumentException("No item with id '" + id + "'.");
+ return item;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java
new file mode 100644
index 00000000000..20ef9f4e5cc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java
@@ -0,0 +1,71 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.EquivItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.NearItem;
+import com.yahoo.prelude.query.ONearItem;
+import com.yahoo.prelude.query.OrItem;
+import com.yahoo.prelude.query.PhraseItem;
+import com.yahoo.prelude.query.RankItem;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author tonytv
+ */
+public class ItemExecutorRegistry {
+
+ private static final Map<String, ItemFormConverter> executorsByName = new HashMap<>();
+ static {
+ register(Item.ItemType.AND, createCompositeConverter(AndItem.class));
+ register(Item.ItemType.OR, createCompositeConverter(OrItem.class));
+ register(Item.ItemType.RANK, createCompositeConverter(RankItem.class));
+ register(Item.ItemType.PHRASE, createCompositeConverter(PhraseItem.class));
+ register(Item.ItemType.EQUIV, createCompositeConverter(EquivItem.class));
+
+ register(AndNotRestConverter.andNotRest, new AndNotRestConverter());
+
+ register(Item.ItemType.NEAR, new NearConverter(NearItem.class));
+ register(Item.ItemType.ONEAR, new NearConverter(ONearItem.class));
+
+ register(Item.ItemType.WORD, new WordConverter());
+ register(Item.ItemType.INT, new IntConverter());
+ register(Item.ItemType.PREFIX, new PrefixConverter());
+ register(Item.ItemType.SUBSTRING, new SubStringConverter());
+ register(Item.ItemType.EXACT, new ExactStringConverter());
+ register(Item.ItemType.SUFFIX, new SuffixConverter());
+ }
+
+ private static <T extends CompositeItem> ItemFormConverter createCompositeConverter(Class<T> itemClass) {
+ return new CompositeConverter<>(itemClass);
+ }
+
+ private static void register(Item.ItemType type, ItemFormConverter executor) {
+ register(type.toString(), executor);
+ }
+
+ private static void register(String type, ItemFormConverter executor) {
+ executorsByName.put(type, executor);
+ }
+
+ public static ItemFormConverter getByName(String name) {
+ ItemFormConverter executor = executorsByName.get(name);
+ ensureNotNull(executor, name);
+ return executor;
+ }
+
+ private static void ensureNotNull(ItemFormConverter executor, String name) {
+ if (executor == null) {
+ throw new RuntimeException("No item type named '" + name + "'.");
+ }
+ }
+
+ public static ItemFormConverter getByType(Item.ItemType itemType) {
+ String name = (itemType == Item.ItemType.NOT) ? AndNotRestConverter.andNotRest : itemType.name();
+ return getByName(name);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java
new file mode 100644
index 00000000000..256ad569686
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.search.query.textserialize.serializer.DispatchForm;
+import com.yahoo.search.query.textserialize.serializer.ItemIdMapper;
+
+/**
+ * @author tonytv
+ */
+public interface ItemFormConverter {
+ Object formToItem(String name, ItemArguments arguments, ItemContext context);
+ DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper);
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java
new file mode 100644
index 00000000000..81b13a107c8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java
@@ -0,0 +1,17 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.search.query.textserialize.parser.DispatchFormHandler;
+
+import java.util.List;
+
+/**
+ * @author tonytv
+ */
+public class ItemFormHandler implements DispatchFormHandler{
+ @Override
+ public Object dispatch(String name, List<Object> arguments, Object dispatchContext) {
+ ItemFormConverter executor = ItemExecutorRegistry.getByName(name);
+ return executor.formToItem(name, new ItemArguments(arguments), (ItemContext)dispatchContext);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java
new file mode 100644
index 00000000000..ae54165abef
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java
@@ -0,0 +1,137 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.IndexedItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.TaggableItem;
+import com.yahoo.search.query.textserialize.serializer.DispatchForm;
+import com.yahoo.search.query.textserialize.serializer.ItemIdMapper;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author tonytv
+ */
+public class ItemInitializer {
+ private static final String indexProperty = "index";
+ private static final String idProperty = "id";
+ private static final String significanceProperty = "significance";
+ private static final String uniqueIdProperty = "uniqueId";
+ private static final String weightProperty = "weight";
+
+ public static void initialize(Item item, ItemArguments arguments, ItemContext itemContext) {
+ storeIdInContext(item, arguments.properties, itemContext);
+
+ Object weight = arguments.properties.get(weightProperty);
+ if (weight != null) {
+ TypeCheck.ensureInstanceOf(weight, Number.class);
+ item.setWeight(((Number)weight).intValue());
+ }
+
+ if (item instanceof TaggableItem) {
+ initializeTaggableItem((TaggableItem)item, arguments, itemContext);
+ }
+
+ if (item instanceof IndexedItem) {
+ initializeIndexedItem((IndexedItem)item, arguments, itemContext);
+ }
+ }
+
+ private static void storeIdInContext(Item item, Map<?, ?> properties, ItemContext itemContext) {
+ Object id = properties.get("id");
+ if (id != null) {
+ TypeCheck.ensureInstanceOf(id, String.class);
+ itemContext.setItemId((String) id, item);
+ }
+ }
+
+ private static void initializeTaggableItem(TaggableItem item, ItemArguments arguments, ItemContext itemContext) {
+ Object connectivity = arguments.properties.get("connectivity");
+ if (connectivity != null) {
+ storeConnectivityInContext(item, connectivity, itemContext);
+ }
+
+ Object significance = arguments.properties.get(significanceProperty);
+ if (significance != null) {
+ TypeCheck.ensureInstanceOf(significance, Number.class);
+ item.setSignificance(((Number)significance).doubleValue());
+ }
+
+ Object uniqueId = arguments.properties.get(uniqueIdProperty);
+ if (uniqueId != null) {
+ TypeCheck.ensureInstanceOf(uniqueId, Number.class);
+ item.setUniqueID(((Number)uniqueId).intValue());
+ }
+ }
+
+ private static void initializeIndexedItem(IndexedItem indexedItem, ItemArguments arguments, ItemContext itemContext) {
+ Object index = arguments.properties.get(indexProperty);
+ if (index != null) {
+ TypeCheck.ensureInstanceOf(index, String.class);
+ indexedItem.setIndexName((String) index);
+ }
+ }
+
+ private static void storeConnectivityInContext(TaggableItem item, Object connectivity, ItemContext itemContext) {
+ TypeCheck.ensureInstanceOf(connectivity, List.class);
+ List<?> connectivityList = (List<?>) connectivity;
+ if (connectivityList.size() != 2) {
+ throw new IllegalArgumentException("Expected two elements for connectivity, got " + connectivityList.size());
+ }
+
+ Object id = connectivityList.get(0);
+ Object strength = connectivityList.get(1);
+
+ TypeCheck.ensureInstanceOf(id, String.class);
+ TypeCheck.ensureInstanceOf(strength, Number.class);
+
+ itemContext.setConnectivity(item, (String)id, ((Number)strength).doubleValue());
+ }
+
+ public static void initializeForm(DispatchForm form, Item item, ItemIdMapper itemIdMapper) {
+ if (item.getWeight() != Item.DEFAULT_WEIGHT) {
+ form.setProperty(weightProperty, item.getWeight());
+ }
+
+ if (item instanceof IndexedItem) {
+ initializeIndexedForm(form, (IndexedItem) item);
+ }
+ if (item instanceof TaggableItem) {
+ initializeTaggableForm(form, (TaggableItem) item, itemIdMapper);
+ }
+ initializeFormWithIdIfConnected(form, item, itemIdMapper);
+ }
+
+ private static void initializeFormWithIdIfConnected(DispatchForm form, Item item, ItemIdMapper itemIdMapper) {
+ if (item.hasConnectivityBackLink()) {
+ form.setProperty(idProperty, itemIdMapper.getId(item));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void initializeTaggableForm(DispatchForm form, TaggableItem taggableItem, ItemIdMapper itemIdMapper) {
+ Item connectedItem = taggableItem.getConnectedItem();
+ if (connectedItem != null) {
+ form.setProperty("connectivity",
+ Arrays.asList(itemIdMapper.getId(connectedItem), taggableItem.getConnectivity()));
+ }
+
+ if (taggableItem.hasExplicitSignificance()) {
+ form.setProperty(significanceProperty, taggableItem.getSignificance());
+ }
+
+ if (taggableItem.hasUniqueID()) {
+ form.setProperty(uniqueIdProperty, taggableItem.getUniqueID());
+ }
+ }
+
+ private static void initializeIndexedForm(DispatchForm form, IndexedItem item) {
+ String index = item.getIndexName();
+ if (!index.isEmpty()) {
+ form.setProperty(indexProperty, index);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java
new file mode 100644
index 00000000000..9349b01a3bc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import java.util.*;
+
+/**
+ * @author tonytv
+ */
+public class ListUtil {
+ public static <T> List<T> rest(List<T> list) {
+ return list.subList(1, list.size());
+ }
+
+ public static <T> T first(Collection<T> collection) {
+ return collection.iterator().next();
+ }
+
+ public static boolean firstInstanceOf(Collection<?> collection, @SuppressWarnings("rawtypes") Class c) {
+ return !collection.isEmpty() && c.isInstance(first(collection));
+ }
+
+ public static <T> List<T> butFirst(List<T> list) {
+ return list.subList(1, list.size());
+ }
+
+ public static <T> Iterable<T> butFirst(final Collection<T> collection) {
+ return () -> {
+ Iterator<T> i = collection.iterator();
+ i.next();
+ return i;
+ };
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java
new file mode 100644
index 00000000000..3be8d3d1c65
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java
@@ -0,0 +1,44 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.NearItem;
+import com.yahoo.search.query.textserialize.serializer.DispatchForm;
+import com.yahoo.search.query.textserialize.serializer.ItemIdMapper;
+
+/**
+ * @author tonytv
+ */
+@SuppressWarnings("rawtypes")
+public class NearConverter extends CompositeConverter {
+ final private String distanceProperty = "distance";;
+
+ @SuppressWarnings("unchecked")
+ public NearConverter(Class<? extends NearItem> nearItemClass) {
+ super(nearItemClass);
+ }
+
+ @Override
+ public Object formToItem(String name, ItemArguments arguments, ItemContext itemContext) {
+ NearItem nearItem = (NearItem) super.formToItem(name, arguments, itemContext);
+ setDistance(nearItem, arguments);
+ return nearItem;
+ }
+
+ private void setDistance(NearItem nearItem, ItemArguments arguments) {
+ Object distance = arguments.properties.get(distanceProperty);
+ if (distance != null) {
+ TypeCheck.ensureInteger(distance);
+ nearItem.setDistance(((Number)distance).intValue());
+ }
+ }
+
+ @Override
+ public DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper) {
+ DispatchForm dispatchForm = super.itemToForm(item, itemIdMapper);
+
+ NearItem nearItem = (NearItem)item;
+ dispatchForm.setProperty(distanceProperty, nearItem.getDistance());
+ return dispatchForm;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java
new file mode 100644
index 00000000000..cb3a6c1943c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.PrefixItem;
+
+/**
+ * @author tonytv
+ */
+public class PrefixConverter extends WordConverter {
+ @Override
+ PrefixItem newTermItem(String word) {
+ return new PrefixItem(word);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java
new file mode 100644
index 00000000000..e61a189684f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.SubstringItem;
+
+/**
+ * @author tonytv
+ */
+public class SubStringConverter extends WordConverter {
+ @Override
+ SubstringItem newTermItem(String word) {
+ return new SubstringItem(word);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java
new file mode 100644
index 00000000000..4390e3464d2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.SuffixItem;
+
+/**
+ * @author tonytv
+ */
+public class SuffixConverter extends WordConverter {
+ @Override
+ SuffixItem newTermItem(String word) {
+ return new SuffixItem(word);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java
new file mode 100644
index 00000000000..8bc6cba7f67
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java
@@ -0,0 +1,53 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.TermItem;
+import com.yahoo.search.query.textserialize.serializer.DispatchForm;
+import com.yahoo.search.query.textserialize.serializer.ItemIdMapper;
+
+/**
+ * @author tonytv
+ */
+public abstract class TermConverter implements ItemFormConverter {
+ @Override
+ public Object formToItem(String name, ItemArguments arguments, ItemContext context) {
+ ensureOnlyOneChild(arguments);
+ String word = getWord(arguments);
+
+ TermItem item = newTermItem(word);
+ ItemInitializer.initialize(item, arguments, context);
+ return item;
+ }
+
+ abstract TermItem newTermItem(String word);
+
+
+ private void ensureOnlyOneChild(ItemArguments arguments) {
+ if (arguments.children.size() != 1) {
+ throw new IllegalArgumentException("Expected exactly one argument, got '" +
+ arguments.children.toString() + "'");
+ }
+ }
+
+ private String getWord(ItemArguments arguments) {
+ Object word = arguments.children.get(0);
+
+ if (!(word instanceof String)) {
+ throw new RuntimeException("Expected string, got '" + word + "' [" + word.getClass().getName() + "].");
+ }
+ return (String)word;
+ }
+
+ @Override
+ public DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper) {
+ TermItem termItem = (TermItem)item;
+
+ DispatchForm form = new DispatchForm(termItem.getItemType().name());
+ ItemInitializer.initializeForm(form, item, itemIdMapper);
+ form.addChild(getValue(termItem));
+ return form;
+ }
+
+ protected abstract String getValue(TermItem item);
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java
new file mode 100644
index 00000000000..a6e38d288a4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java
@@ -0,0 +1,27 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.protect.Validator;
+
+/**
+ * @author tonytv
+ */
+public class TypeCheck {
+ public static void ensureInstanceOf(Object object, Class<?> c) {
+ Validator.ensureInstanceOf(expectationString(c.getName(), object.getClass().getSimpleName()),
+ object, c);
+ }
+
+ public static void ensureInteger(Object value) {
+ ensureInstanceOf(value, Number.class);
+ Number number = (Number)value;
+
+ int intValue = number.intValue();
+ if (intValue != number.doubleValue())
+ throw new IllegalArgumentException("Invalid integer '" + number + "'");
+ }
+
+ private static String expectationString(String expected, String got) {
+ return "Expected " + expected + ", but got " + got;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java
new file mode 100644
index 00000000000..dce33e392ae
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java
@@ -0,0 +1,20 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.item;
+
+import com.yahoo.prelude.query.TermItem;
+import com.yahoo.prelude.query.WordItem;
+
+/**
+ * @author tonytv
+ */
+public class WordConverter extends TermConverter {
+ @Override
+ WordItem newTermItem(String word) {
+ return new WordItem(word);
+ }
+
+ @Override
+ protected String getValue(TermItem item) {
+ return ((WordItem)item).getWord();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java
new file mode 100644
index 00000000000..1e1d3052731
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.query.textserialize;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore
new file mode 100644
index 00000000000..add88bd6807
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore
@@ -0,0 +1,7 @@
+/TokenMgrError.java
+/Token.java
+/SimpleCharStream.java
+/ParserTokenManager.java
+/ParserConstants.java
+/ParseException.java
+/Parser.java
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java
new file mode 100644
index 00000000000..33c8e36bd57
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java
@@ -0,0 +1,11 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.parser;
+
+import java.util.List;
+
+/**
+ * @author tonytv
+ */
+public interface DispatchFormHandler {
+ Object dispatch(String name, List<Object> arguments, Object dispatchContext);
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java
new file mode 100644
index 00000000000..091efa0a01b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java
@@ -0,0 +1,56 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.serializer;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author tonytv
+ */
+public class DispatchForm {
+ private final String name;
+ public final Map<Object, Object> properties = new LinkedHashMap<>();
+ public final List<Object> children = new ArrayList<>();
+
+ public DispatchForm(String name) {
+ this.name = name;
+ }
+
+ public void addChild(Object child) {
+ children.add(child);
+ }
+
+ /**
+ * Only public for the purpose of testing.
+ */
+ public String serialize(ItemIdMapper itemIdMapper) {
+ StringBuilder builder = new StringBuilder();
+ builder.append('(').append(name);
+
+ serializeProperties(builder, itemIdMapper);
+ serializeChildren(builder, itemIdMapper);
+
+ builder.append(')');
+ return builder.toString();
+ }
+
+ private void serializeProperties(StringBuilder builder, ItemIdMapper itemIdMapper) {
+ if (properties.isEmpty())
+ return;
+
+ builder.append(' ').append(Serializer.serializeMap(properties, itemIdMapper));
+ }
+
+
+ private void serializeChildren(StringBuilder builder, ItemIdMapper itemIdMapper) {
+ for (Object child : children) {
+ builder.append(' ').append(Serializer.serialize(child, itemIdMapper));
+ }
+ }
+
+ public void setProperty(Object key, Object value) {
+ properties.put(key, value);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java
new file mode 100644
index 00000000000..c32a7f52c0a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.serializer;
+
+import com.yahoo.prelude.query.Item;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+/**
+ * @author tonytv
+ */
+public class ItemIdMapper {
+ private final Map<Item, String> idByItem = new IdentityHashMap<>();
+ private int idCounter = 0;
+
+ public String getId(Item item) {
+ String id = idByItem.get(item);
+ if (id != null) {
+ return id;
+ } else {
+ idByItem.put(item, generateId(item));
+ return getId(item);
+ }
+ }
+
+ private String generateId(Item item) {
+ return item.getName() + "_" + nextCount();
+ }
+
+ private int nextCount() {
+ return idCounter++;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java
new file mode 100644
index 00000000000..e3090930369
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java
@@ -0,0 +1,16 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.serializer;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.search.query.textserialize.item.ItemExecutorRegistry;
+
+
+/**
+ * @author tonytv
+ */
+public class QueryTreeSerializer {
+ public String serialize(Item root) {
+ ItemIdMapper itemIdMapper = new ItemIdMapper();
+ return ItemExecutorRegistry.getByType(root.getItemType()).itemToForm(root, itemIdMapper).serialize(itemIdMapper);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java
new file mode 100644
index 00000000000..e8352254551
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java
@@ -0,0 +1,79 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.query.textserialize.serializer;
+
+import com.yahoo.prelude.query.Item;
+import com.yahoo.search.query.textserialize.item.ItemExecutorRegistry;
+
+import java.util.List;
+import java.util.Map;
+
+import static com.yahoo.search.query.textserialize.item.ListUtil.butFirst;
+import static com.yahoo.search.query.textserialize.item.ListUtil.first;
+
+/**
+ * @author tonytv
+ */
+class Serializer {
+ static String serialize(Object child, ItemIdMapper itemIdMapper) {
+ if (child instanceof DispatchForm) {
+ return ((DispatchForm) child).serialize(itemIdMapper);
+ } else if (child instanceof Item) {
+ return serializeItem((Item) child, itemIdMapper);
+ } else if (child instanceof String) {
+ return serializeString((String) child);
+ } else if (child instanceof Number) {
+ return child.toString();
+ } else if (child instanceof Map) {
+ return serializeMap((Map<?, ?>)child, itemIdMapper);
+ } else if (child instanceof List) {
+ return serializeList((List<?>)child, itemIdMapper);
+ } else {
+ throw new IllegalArgumentException("Can't serialize type " + child.getClass());
+ }
+ }
+
+ private static String serializeString(String string) {
+ return '"' + string.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
+ }
+
+ static String serializeList(List<?> list, ItemIdMapper itemIdMapper) {
+ StringBuilder builder = new StringBuilder();
+ builder.append('[');
+
+ if (!list.isEmpty()) {
+ builder.append(serialize(first(list), itemIdMapper));
+
+ for (Object element : butFirst(list)) {
+ builder.append(", ").append(serialize(element, itemIdMapper));
+ }
+ }
+
+ builder.append(']');
+ return builder.toString();
+ }
+
+ static String serializeMap(Map<?, ?> map, ItemIdMapper itemIdMapper) {
+ StringBuilder builder = new StringBuilder();
+ builder.append("{");
+
+ if (!map.isEmpty()) {
+ serializeEntry(builder, first(map.entrySet()), itemIdMapper);
+ for (Map.Entry<?, ?> entry : butFirst(map.entrySet())) {
+ builder.append(", ");
+ serializeEntry(builder, entry, itemIdMapper);
+ }
+ }
+
+ builder.append('}');
+ return builder.toString();
+ }
+
+ static void serializeEntry(StringBuilder builder, Map.Entry<?, ?> entry, ItemIdMapper itemIdMapper) {
+ builder.append(serialize(entry.getKey(), itemIdMapper)).append(' ').
+ append(serialize(entry.getValue(), itemIdMapper));
+ }
+
+ static String serializeItem(Item item, ItemIdMapper itemIdMapper) {
+ return ItemExecutorRegistry.getByType(item.getItemType()).itemToForm(item, itemIdMapper).serialize(itemIdMapper);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/AllLowercasingSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/AllLowercasingSearcher.java
new file mode 100644
index 00000000000..deed9e20aa5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/AllLowercasingSearcher.java
@@ -0,0 +1,31 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import java.util.Collection;
+
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.WordItem;
+
+/**
+ * Transform all terms in the incoming query tree and highlight terms to lower
+ * case. This searcher is a compatibility layer for customers needing to use
+ * FSAs created for pre-5.1 systems.
+ *
+ * <p>
+ * Add this searcher to your search chain before any searcher running
+ * case-dependent automata with only lowercased contents, query transformers
+ * assuming lowercased input etc. Refer to the Vespa documentation on search
+ * chains and search chain ordering.
+ * </p>
+ *
+ * @since 5.1.3.
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class AllLowercasingSearcher extends LowercasingSearcher {
+
+ @Override
+ public boolean shouldLowercase(WordItem word, IndexFacts.Session settings) {
+ return true;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/BooleanAttributeParser.java b/container-search/src/main/java/com/yahoo/search/querytransform/BooleanAttributeParser.java
new file mode 100644
index 00000000000..902de89c94e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/BooleanAttributeParser.java
@@ -0,0 +1,170 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.text.PositionedString;
+import com.yahoo.text.SimpleMapParser;
+
+import java.math.BigInteger;
+
+/**
+ * Parses an attribute string on the format <code>{attribute:value, ...}</code>
+ * where <code>value</code>' is either a single value or a list of values
+ * <code>[value1,value2,...]</code>, and each of the values can have an optional
+ * bitmap specified <code>value:bitmap</code>. <code>bitmap</code> can be either
+ * a 64-bit hex number <code>0x1234</code> or a list of bits <code>[0, 2, 43,
+ * 22, ...]</code>.
+ *
+ * @author <a href="mailto:magnarn@yahoo-inc.com">Magnar Nedland</a>
+ * @since 5.1.15
+ */
+abstract class BooleanAttributeParser extends SimpleMapParser {
+ private boolean isMap = true;
+
+ @Override
+ public void parse(String s) {
+ if (s == null || s.length() == 0) return;
+ super.parse(s);
+ if (string().position() != string().string().length()) {
+ throw new IllegalArgumentException("Expected end of string " + string().at());
+ }
+ }
+
+ // Value ends at ',' or '}' for map, and at ',' or ']' for list.
+ @Override
+ protected int findEndOfValue() {
+ if (isMap) {
+ return findNextButSkipLists(new char[]{',','}'}, string().string(), string().position());
+ }
+ return findNextButSkipLists(new char[]{',',']'}, string().string(), string().position());
+ }
+
+ @Override
+ protected void handleKeyValue(String attribute, String value) {
+ // string() will point to the start of value.
+ if (string().peek('[') && isMap) {
+ // begin parsing MultiValueQueryTerm
+ isMap = false;
+ parseMultiValue(attribute);
+ isMap = true;
+ } else {
+ handleAttribute(attribute, value);
+ }
+ }
+
+ /**
+ * Parses a list of values for a given attribute. When calling this
+ * function, string() must point to the start of the list.
+ */
+ private void parseMultiValue(String attribute) {
+ // string() will point to the start of value.
+ string().consume('[');
+ while (!string().peek(']')) {
+ string().consumeSpaces();
+ consumeValue(attribute);
+ string().consumeOptional(',');
+ string().consumeSpaces();
+ }
+ }
+
+ /**
+ * Handles one attribute, possibly with a subquery bitmap.
+ * @param attribute Attribute name
+ * @param value Either value, or value:bitmap, where bitmap is either a 64-bit hex number or a list of bits.
+ */
+ private void handleAttribute(String attribute, String value) {
+ int pos = value.indexOf(':');
+ if (pos != -1) {
+ parseBitmap(attribute, value.substring(0, pos), value.substring(pos + 1));
+ } else {
+ addAttribute(attribute, value);
+ }
+ }
+
+ // Parses a bitmap string that's either a list of bits or a hex number.
+ private void parseBitmap(String attribute, String value, String bitmap) {
+ if (bitmap.charAt(0) == '[') {
+ parseBitmapList(attribute, value, bitmap);
+ } else {
+ parseBitmapHex(attribute, value, bitmap);
+ }
+ }
+
+ /**
+ * Adds attributes with the specified bitmap to normalizer.
+ * @param attribute Attribute to add
+ * @param value Value of attribute
+ * @param bitmap Bitmap as a hex number, with a '0x' prefix.
+ */
+ private void parseBitmapHex(String attribute, String value, String bitmap) {
+ PositionedString s = new PositionedString(bitmap);
+ s.consume('0');
+ s.consume('x');
+ addAttribute(attribute, value, new BigInteger(s.substring().trim(),16));
+ }
+
+ /**
+ * Adds attributes with the specified bitmap to normalizer.
+ * @param attribute Attribute to add
+ * @param value Value of attribute
+ * @param bitmap Bitmap as a list of bits, e.g. '[0, 3, 45]'
+ */
+ private void parseBitmapList(String attribute, String value, String bitmap) {
+ PositionedString s = new PositionedString(bitmap);
+ s.consume('[');
+ BigInteger mask = BigInteger.ZERO;
+ while (!s.peek(']')) {
+ s.consumeSpaces();
+ int pos = findNextButSkipLists(new char[]{',',']'}, s.string(), s.position());
+ if (pos == -1) {
+ break;
+ }
+ int subqueryIndex = Integer.parseUnsignedInt(s.substring(pos).trim());
+ if (subqueryIndex > 63 || subqueryIndex < 0) {
+ throw new IllegalArgumentException("Subquery index must be in the range 0-63");
+ }
+ mask = mask.or(BigInteger.ONE.shiftLeft(subqueryIndex));
+ s.setPosition(pos);
+ s.consumeOptional(',');
+ s.consumeSpaces();
+ }
+ addAttribute(attribute, value, mask);
+ }
+
+ /**
+ * Add an attribute without a subquery mask
+ * @param attribute name of attribute
+ * @param value value of attribute
+ */
+ protected abstract void addAttribute(String attribute, String value);
+
+ /**
+ * Add an attribute with a subquery mask
+ * @param attribute name of attribute
+ * @param value value of attribute
+ * @param subqueryMask subquery mask for attribute (64-bit)
+ */
+ protected abstract void addAttribute(String attribute, String value, BigInteger subqueryMask);
+
+ /**
+ * Finds next index of a set of chars, but skips past any lists ("[...]").
+ * @param chars Characters to find. Note that '[' should not be in this list.
+ * @param s String to search
+ * @param position position in s to start at.
+ * @return position of first char from "chars" that does not appear within brackets.
+ */
+ private static int findNextButSkipLists(char[] chars, String s, int position) {
+ for (; position<s.length(); position++) {
+ if (s.charAt(position)=='[') {
+ position = findNextButSkipLists(new char[]{']'}, s, position + 1);
+ if (position<0) return -1;
+ } else {
+ for (char c : chars) {
+ if (s.charAt(position)==c)
+ return position;
+ }
+ }
+ }
+ return -1;
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/BooleanSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/BooleanSearcher.java
new file mode 100644
index 00000000000..1fd394acd54
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/BooleanSearcher.java
@@ -0,0 +1,113 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.prelude.query.PredicateQueryItem;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.grouping.request.parser.TokenMgrError;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+
+import java.math.BigInteger;
+
+import static com.yahoo.prelude.querytransform.NormalizingSearcher.ACCENT_REMOVAL;
+import static com.yahoo.prelude.querytransform.StemmingSearcher.STEMMING;
+import static com.yahoo.yolean.Exceptions.toMessageString;
+
+/**
+ * Searcher that builds a PredicateItem from the &amp;boolean properties and inserts it into a query.
+ * @author <a href="mailto:magnarn@yahoo-inc.com">Magnar Nedland</a>
+ */
+@After({ STEMMING, ACCENT_REMOVAL })
+@Provides(BooleanSearcher.PREDICATE)
+public class BooleanSearcher extends Searcher {
+ private static final CompoundName FIELD = new CompoundName("boolean.field");
+ private static final CompoundName ATTRIBUTES = new CompoundName("boolean.attributes");
+ private static final CompoundName RANGE_ATTRIBUTES = new CompoundName("boolean.rangeAttributes");
+ public static final String PREDICATE = "predicate";
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ String fieldName = query.properties().getString(FIELD);
+ if (fieldName != null) {
+ return search(query, execution, fieldName);
+ } else {
+ if (query.isTraceable(5)) {
+ query.trace("BooleanSearcher: Nothing added to query", false, 5);
+ }
+ }
+ return execution.search(query);
+ }
+
+ private Result search(Query query, Execution execution, String fieldName) {
+ String attributes = query.properties().getString(ATTRIBUTES);
+ String rangeAttributes = query.properties().getString(RANGE_ATTRIBUTES);
+ if (query.isTraceable(5)) {
+ query.trace("BooleanSearcher: fieldName(" + fieldName + "), attributes(" + attributes +
+ "), rangeAttributes(" + rangeAttributes + ")", false, 5);
+ }
+
+ if (attributes != null || rangeAttributes != null) {
+ try {
+ addPredicateTerm(query, fieldName, attributes, rangeAttributes);
+ if (query.isTraceable(4)) {
+ query.trace("BooleanSearcher: Added boolean operator", true, 4);
+ }
+ } catch (TokenMgrError e) {
+ return new Result(query, ErrorMessage.createInvalidQueryParameter(toMessageString(e)));
+ }
+ } else {
+ if (query.isTraceable(5)) {
+ query.trace("BooleanSearcher: Nothing added to query", false, 5);
+ }
+ }
+ return execution.search(query);
+ }
+
+ // Adds a boolean term ANDed to the query, based on the supplied properties.
+ private void addPredicateTerm(Query query, String fieldName, String attributes, String rangeAttributes) {
+ PredicateQueryItem item = new PredicateQueryItem();
+ item.setIndexName(fieldName);
+ new PredicateValueAttributeParser(item).parse(attributes);
+ new PredicateRangeAttributeParser(item).parse(rangeAttributes);
+ QueryTreeUtil.andQueryItemWithRoot(query, item);
+ }
+
+ static public class PredicateValueAttributeParser extends BooleanAttributeParser {
+ private PredicateQueryItem item;
+ public PredicateValueAttributeParser(PredicateQueryItem item) {
+ this.item = item;
+ }
+
+ @Override
+ protected void addAttribute(String attribute, String value) {
+ item.addFeature(attribute, value);
+ }
+
+ @Override
+ protected void addAttribute(String attribute, String value, BigInteger subQueryMask) {
+ item.addFeature(attribute, value, subQueryMask.longValue());
+ }
+ }
+
+ static private class PredicateRangeAttributeParser extends BooleanAttributeParser {
+ private PredicateQueryItem item;
+ public PredicateRangeAttributeParser(PredicateQueryItem item) {
+ this.item = item;
+ }
+
+ @Override
+ protected void addAttribute(String attribute, String value) {
+ item.addRangeFeature(attribute, Long.parseLong(value));
+ }
+
+ @Override
+ protected void addAttribute(String attribute, String value, BigInteger subQueryMask) {
+ item.addRangeFeature(attribute, Long.parseLong(value), subQueryMask.longValue());
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/DefaultPositionSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/DefaultPositionSearcher.java
new file mode 100644
index 00000000000..c2d462a17e4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/DefaultPositionSearcher.java
@@ -0,0 +1,47 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import static com.yahoo.prelude.searcher.PosSearcher.POSITION_PARSING;
+
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.Location;
+import com.yahoo.search.Query;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.PhaseNames;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * If default position has not been set, it will be set here.
+ *
+ * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a>
+ */
+@After({PhaseNames.RAW_QUERY, POSITION_PARSING})
+@Before(PhaseNames.TRANSFORMED_QUERY)
+public class DefaultPositionSearcher extends Searcher {
+
+ @Override
+ public com.yahoo.search.Result search(Query query, Execution execution) {
+ Location location = query.getRanking().getLocation();
+ if (location != null && (location.getAttribute() == null)) {
+ IndexFacts facts = execution.context().getIndexFacts();
+ List<String> search = facts.newSession(query.getModel().getSources(), query.getModel().getRestrict()).documentTypes();
+
+ for (String sd : search) {
+ String defaultPosition = facts.getDefaultPosition(sd);
+ if (defaultPosition != null) {
+ location.setAttribute(defaultPosition);
+ }
+ }
+ if (location.getAttribute() == null) {
+ location.setAttribute(facts.getDefaultPosition(null));
+ }
+ }
+ return execution.search(query);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/LegacyCombinator.java b/container-search/src/main/java/com/yahoo/search/querytransform/LegacyCombinator.java
new file mode 100644
index 00000000000..41af5736da7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/LegacyCombinator.java
@@ -0,0 +1,365 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.language.Language;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.Index;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.IndexedItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.NotItem;
+import com.yahoo.prelude.query.NullItem;
+import com.yahoo.prelude.query.RankItem;
+import com.yahoo.prelude.query.parser.CustomParser;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.query.QueryTree;
+import com.yahoo.search.query.parser.ParserEnvironment;
+import com.yahoo.search.query.parser.ParserFactory;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * Compatibility layer to implement the old multi part query syntax, along with
+ * the features of QueryCombinator. Do <b>not</b> use both QueryCombinator and
+ * LegacyCombinator in a single search.
+ *
+ * <p>
+ * A searcher which grabs query parameters of the form
+ * "defidx.(identifier)=(index name)" and "query.(identifier)=(user query)",
+ * parses them and adds them as AND items to the query root.
+ *
+ * <p>
+ * If the given default index does not exist in the search definition, the query
+ * part will be parsed with the settings of the default index set to "".
+ *
+ * <p>
+ * If any of the following arguments exist, they will be used:
+ *
+ * <p>
+ * query.(identifier)=query string<br>
+ * query.(identifier).operator={"req", "rank", "not"}, where "req" is default<br>
+ * query.(identifier).defidx=default index<br>
+ * query.(identifier).type={"all", "any", "phrase", "adv", "web"} where "all" is
+ * default
+ *
+ * <p>
+ * If both defidx.(identifier) and any of
+ * query.(identifier).{operator,defidx,type} is present in the query, an
+ * InvalidQueryParameter error will be added, and the query will be passed
+ * through untransformed.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@Before({"transformedQuery", "com.yahoo.prelude.querytransform.StemmingSearcher"})
+public class LegacyCombinator extends Searcher {
+
+ private static final String TYPESUFFIX = ".type";
+ private static final String OPERATORSUFFIX = ".operator";
+ private static final String DEFIDXSUFFIX = ".defidx";
+ private static final String DEFIDXPREFIX = "defidx.";
+ private static final String QUERYPREFIX = "query.";
+
+ private enum Combinator {
+ REQUIRED("req"), PREFERRED("rank"), EXCLUDED("not");
+
+ String parameterValue;
+
+ private Combinator(String parameterValue) {
+ this.parameterValue = parameterValue;
+ }
+
+ static Combinator getCombinator(String name) {
+ for (Combinator c : Combinator.values()) {
+ if (c.parameterValue.equals(name)) {
+ return c;
+ }
+ }
+ return REQUIRED;
+ }
+ }
+
+ private static class QueryPart {
+ final String query;
+ final String defaultIndex;
+ final Combinator operator;
+ final String identifier;
+ final Query.Type syntax;
+
+ QueryPart(String identifier, String defaultIndex, String oldIndex,
+ String operator, String query, String syntax) {
+ validateArguments(identifier, defaultIndex, oldIndex,
+ operator,syntax);
+ this.query = query;
+ if (defaultIndex != null) {
+ this.defaultIndex = defaultIndex;
+ } else {
+ this.defaultIndex = oldIndex;
+ }
+ this.operator = Combinator.getCombinator(operator);
+ this.identifier = identifier;
+ this.syntax = Query.Type.getType(syntax);
+ }
+
+ private static void validateArguments(String identifier, String defaultIndex,
+ String oldIndex, String operator, String syntax) {
+ if (defaultIndex == null) {
+ return;
+ }
+ if (oldIndex != null) {
+ throw new IllegalArgumentException(createErrorMessage(identifier, DEFIDXSUFFIX));
+ }
+ if (operator != null) {
+ throw new IllegalArgumentException(createErrorMessage(identifier, OPERATORSUFFIX));
+ }
+ if (syntax != null) {
+ throw new IllegalArgumentException(createErrorMessage(identifier, TYPESUFFIX));
+ }
+ }
+
+ private static String createErrorMessage(String identifier, String legacyArgument) {
+ return "Cannot set both " + DEFIDXPREFIX + identifier + " and "
+ + QUERYPREFIX + identifier + legacyArgument + ".";
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result
+ + ((identifier == null) ? 0 : identifier.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ QueryPart other = (QueryPart) obj;
+ if (identifier == null) {
+ if (other.identifier != null)
+ return false;
+ } else if (!identifier.equals(other.identifier))
+ return false;
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "QueryPart(" + identifier + ", " + defaultIndex + ", "
+ + operator + ", " + syntax + ")";
+ }
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ Set<QueryPart> pieces;
+ Set<String> usedSources;
+ IndexFacts indexFacts = execution.context().getIndexFacts();
+ try {
+ pieces = findQuerySnippets(query.properties());
+ } catch (IllegalArgumentException e) {
+ query.errors().add(ErrorMessage.createInvalidQueryParameter("LegacyCombinator got invalid parameters: "
+ + e.getMessage()));
+ return execution.search(query);
+ }
+ if (pieces.size() == 0) {
+ return execution.search(query);
+ }
+ IndexFacts.Session session = indexFacts.newSession(query);
+ Language language = query.getModel().getParsingLanguage();
+ addAndItems(language, query, pieces, session, execution.context());
+ addRankItems(language, query, pieces, session, execution.context());
+ try {
+ addNotItems(language, query, pieces, session, execution.context());
+ } catch (IllegalArgumentException e) {
+ query.errors().add(ErrorMessage.createInvalidQueryParameter("LegacyCombinator found only excluding terms, no including."));
+ return execution.search(query);
+ }
+ query.trace("Adding extra query parts.", true, 2);
+ return execution.search(query);
+ }
+
+ private void addNotItems(Language language, Query query, Set<QueryPart> pieces,
+ IndexFacts.Session session, Execution.Context context) {
+ for (QueryPart part : pieces) {
+ if (part.operator != Combinator.EXCLUDED) continue;
+
+ String defaultIndex = defaultIndex(session, part);
+ Item item = parse(language, query, part, defaultIndex, context);
+ if (item == null) continue;
+
+ setDefaultIndex(part, defaultIndex, item);
+ addNotItem(query.getModel().getQueryTree(), item);
+ }
+
+ }
+
+ private void addNotItem(QueryTree queryTree, Item item) {
+ Item root = queryTree.getRoot();
+ // JavaDoc claims I can get null, code gives NullItem... well, well, well...
+ if (root instanceof NullItem || root == null) {
+ // errr... no positive branch at all?
+ throw new IllegalArgumentException("No positive terms for query.");
+ } else if (root.getClass() == NotItem.class) {
+ ((NotItem) root).addNegativeItem(item);
+ } else {
+ NotItem newRoot = new NotItem();
+ newRoot.addPositiveItem(root);
+ newRoot.addNegativeItem(item);
+ queryTree.setRoot(newRoot);
+ }
+ }
+
+ private void addRankItems(Language language, Query query, Set<QueryPart> pieces, IndexFacts.Session session, Execution.Context context) {
+ for (QueryPart part : pieces) {
+ if (part.operator != Combinator.PREFERRED) continue;
+
+ String defaultIndex = defaultIndex(session, part);
+ Item item = parse(language, query, part, defaultIndex, context);
+ if (item == null) continue;
+
+ setDefaultIndex(part, defaultIndex, item);
+ addRankItem(query.getModel().getQueryTree(), item);
+ }
+ }
+
+ private void addRankItem(QueryTree queryTree, Item item) {
+ Item root = queryTree.getRoot();
+ // JavaDoc claims I can get null, code gives NullItem... well, well, well...
+ if (root instanceof NullItem || root == null) {
+ queryTree.setRoot(item);
+ } else if (root.getClass() == RankItem.class) {
+ // if no clear recall terms, just set the rank term as recall
+ ((RankItem) root).addItem(item);
+ } else {
+ RankItem newRoot = new RankItem();
+ newRoot.addItem(root);
+ newRoot.addItem(item);
+ queryTree.setRoot(newRoot);
+ }
+ }
+
+ private void addAndItems(Language language, Query query, Iterable<QueryPart> pieces, IndexFacts.Session session, Execution.Context context) {
+ for (QueryPart part : pieces) {
+ if (part.operator != Combinator.REQUIRED) continue;
+
+ String defaultIndex = defaultIndex(session, part);
+ Item item = parse(language, query, part, defaultIndex, context);
+ if (item == null) continue;
+
+ setDefaultIndex(part, defaultIndex, item);
+ addAndItem(query.getModel().getQueryTree(), item);
+ }
+ }
+
+ private void setDefaultIndex(QueryPart part, String defaultIndex, Item item) {
+ if (defaultIndex == null) {
+ assignDefaultIndex(item, part.defaultIndex);
+ }
+ }
+
+ private Item parse(Language language, Query query, QueryPart part, String defaultIndex, Execution.Context context) {
+ Item item = null;
+ try {
+ CustomParser parser = (CustomParser)ParserFactory.newInstance(
+ part.syntax, ParserEnvironment.fromExecutionContext(context));
+ item = parser.parse(part.query, null, language, query.getModel().getSources(),
+ context.getIndexFacts(), defaultIndex);
+ } catch (RuntimeException e) {
+ String err = Exceptions.toMessageString(e);
+ query.trace("Query parser threw an exception: " + err, true, 1);
+ getLogger().log(LogLevel.WARNING,
+ "Query parser threw exception in searcher LegacyCombinator for "
+ + query.getHttpRequest().toString() + ", query part " + part.query + ": " + err);
+ }
+ return item;
+ }
+
+ private String defaultIndex(IndexFacts.Session indexFacts, QueryPart part) {
+ String defaultIndex;
+ if (indexFacts.getIndex(part.defaultIndex) == Index.nullIndex) {
+ defaultIndex = null;
+ } else {
+ defaultIndex = part.defaultIndex;
+ }
+ return defaultIndex;
+ }
+
+ private static void addAndItem(QueryTree queryTree, Item item) {
+ Item root = queryTree.getRoot();
+ // JavaDoc claims I can get null, code gives NullItem... well, well, well...
+ if (root instanceof NullItem || root == null) {
+ queryTree.setRoot(item);
+ } else if (root.getClass() == AndItem.class) {
+ ((AndItem) root).addItem(item);
+ } else {
+ AndItem newRoot = new AndItem();
+ newRoot.addItem(root);
+ newRoot.addItem(item);
+ queryTree.setRoot(newRoot);
+ }
+ }
+
+ private static void assignDefaultIndex(Item item, String defaultIndex) {
+ if (item instanceof IndexedItem) {
+ IndexedItem indexName = (IndexedItem) item;
+
+ if ("".equals(indexName.getIndexName())) {
+ indexName.setIndexName(defaultIndex);
+ }
+ } else if (item instanceof CompositeItem) {
+ Iterator<Item> items = ((CompositeItem) item).getItemIterator();
+ while (items.hasNext()) {
+ Item i = items.next();
+ assignDefaultIndex(i, defaultIndex);
+ }
+ }
+
+ }
+
+ private static Set<QueryPart> findQuerySnippets(Properties properties) {
+ Set<QueryPart> pieces = new HashSet<>();
+ for (Map.Entry<String, Object> k : properties.listProperties()
+ .entrySet()) {
+ String key = k.getKey();
+ if (!key.startsWith(QUERYPREFIX)) {
+ continue;
+ }
+ String name = key.substring(QUERYPREFIX.length());
+ if (hasDots(name)) {
+ continue;
+ }
+ String index = properties.getString(DEFIDXPREFIX + name);
+ String oldIndex = properties.getString(QUERYPREFIX + name
+ + DEFIDXSUFFIX);
+ String operator = properties.getString(QUERYPREFIX + name
+ + OPERATORSUFFIX);
+ String type = properties.getString(QUERYPREFIX + name + TYPESUFFIX);
+ pieces.add(new QueryPart(name, index, oldIndex, operator, k
+ .getValue().toString(), type));
+ }
+ return pieces;
+ }
+
+ private static boolean hasDots(String name) {
+ int index = name.indexOf('.', 0);
+ return index != -1;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/LowercasingSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/LowercasingSearcher.java
new file mode 100644
index 00000000000..d3916c4bfe1
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/LowercasingSearcher.java
@@ -0,0 +1,140 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.*;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static com.yahoo.language.LinguisticsCase.toLowerCase;
+
+/**
+ * Traverse a query tree and lowercase terms based on decision made in subclasses.
+ *
+ * @since 5.1.3
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public abstract class LowercasingSearcher extends Searcher {
+
+ private final boolean transformWeightedSets;
+
+ public LowercasingSearcher() {
+ this(new LowercasingConfig(new LowercasingConfig.Builder()));
+ }
+
+ public LowercasingSearcher(LowercasingConfig cfg) {
+ this.transformWeightedSets = cfg.transform_weighted_sets();
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ IndexFacts.Session indexFacts = execution.context().getIndexFacts().newSession(query);
+ traverse(query.getModel().getQueryTree(), indexFacts);
+ traverseHighlight(query.getPresentation().getHighlight(), indexFacts);
+ query.trace("Lowercasing", true, 2);
+ return execution.search(query);
+ }
+
+ private void traverseHighlight(Highlight highlight, IndexFacts.Session indexFacts) {
+ if (highlight == null) return;
+
+ for (AndItem item : highlight.getHighlightItems().values()) {
+ traverse(item, indexFacts);
+ }
+ }
+
+ private void traverse(CompositeItem base, IndexFacts.Session indexFacts) {
+ for (Iterator<Item> i = base.getItemIterator(); i.hasNext();) {
+ Item next = i.next();
+ if (next instanceof WordItem) {
+ lowerCase((WordItem) next, indexFacts);
+ } else if (next instanceof CompositeItem) {
+ traverse((CompositeItem) next, indexFacts);
+ } else if (next instanceof WeightedSetItem) {
+ if (transformWeightedSets) {
+ lowerCase((WeightedSetItem) next, indexFacts);
+ }
+ } else if (next instanceof WordAlternativesItem) {
+ lowerCase((WordAlternativesItem) next, indexFacts);
+ }
+ }
+ }
+
+ private void lowerCase(WordItem word, IndexFacts.Session indexFacts) {
+ if (shouldLowercase(word, indexFacts)) {
+ word.setWord(toLowerCase(word.getWord()));
+ word.setLowercased(true);
+ }
+ }
+
+ private static final class WeightedSetToken {
+ final String token;
+ final String originalToken;
+ final int weight;
+
+ WeightedSetToken(String token, String originalToken, int weight) {
+ this.token = token;
+ this.originalToken = originalToken;
+ this.weight = weight;
+ }
+ }
+
+ private boolean syntheticLowerCaseCheck(String indexName, IndexFacts.Session indexFacts, boolean isFromQuery) {
+ WordItem w = new WordItem("", indexName, isFromQuery);
+ return shouldLowercase(w, indexFacts);
+ }
+
+ private void lowerCase(WeightedSetItem set, IndexFacts.Session indexFacts) {
+ if (!syntheticLowerCaseCheck(set.getIndexName(), indexFacts, true)) {
+ return;
+ }
+
+ List<WeightedSetToken> terms = new ArrayList<>(set.getNumTokens());
+ for (Iterator<Map.Entry<Object, Integer>> i = set.getTokens(); i.hasNext();) {
+ Map.Entry<Object, Integer> e = i.next();
+ if (e.getKey() instanceof String) {
+ String originalToken = (String) e.getKey();
+ String token = toLowerCase(originalToken);
+ if ( ! originalToken.equals(token)) {
+ terms.add(new WeightedSetToken(token, originalToken, e.getValue().intValue()));
+ }
+ }
+ }
+ // has to do it in two passes on cause of the "interesting" API in
+ // weighted set, and remove before put on cause of the semantics of
+ // addInternal as well as changed values...
+ for (WeightedSetToken t : terms) {
+ set.removeToken(t.originalToken);
+ set.addToken(t.token, t.weight);
+ }
+ }
+
+ private void lowerCase(WordAlternativesItem alternatives, IndexFacts.Session indexFacts) {
+ if (!syntheticLowerCaseCheck(alternatives.getIndexName(), indexFacts, alternatives.isFromQuery())) {
+ return;
+ }
+ for (WordAlternativesItem.Alternative term : alternatives.getAlternatives()) {
+ String lowerCased = toLowerCase(term.word);
+ alternatives.addTerm(lowerCased, term.exactness * .7d);
+ }
+
+ }
+
+ /**
+ * Override this to control whether a given term should be lowercased.
+ *
+ * @param word a WordItem or subclass thereof which is a candidate for lowercasing
+ * @return whether to convert the term to lower case
+ */
+ public abstract boolean shouldLowercase(WordItem word, IndexFacts.Session indexFacts);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/NGramSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/NGramSearcher.java
new file mode 100644
index 00000000000..c487182c65d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/NGramSearcher.java
@@ -0,0 +1,285 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.language.Linguistics;
+import com.yahoo.language.process.CharacterClasses;
+import com.yahoo.language.process.GramSplitter;
+import com.yahoo.prelude.Index;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.hitfield.AnnotateStringFieldPart;
+import com.yahoo.prelude.hitfield.JSONString;
+import com.yahoo.prelude.hitfield.XMLString;
+import com.yahoo.prelude.query.*;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+import static com.yahoo.prelude.searcher.JuniperSearcher.JUNIPER_TAG_REPLACING;
+import static com.yahoo.language.LinguisticsCase.toLowerCase;
+
+/**
+ * Handles NGram indexes by splitting query terms to them into grams and combining summary field values
+ * from such fields into the original text.
+ * <p>
+ * This declares it must be placed after Juniper searchers because it assumes Juniper token separators
+ * (which are returned on bolding) are not replaced by highlight tags when this is run (and "after" means
+ * "before" from the point of view of the result).
+ *
+ * @author bratseth
+ */
+@After(JUNIPER_TAG_REPLACING)
+public class NGramSearcher extends Searcher {
+
+ private final GramSplitter gramSplitter;
+
+ private final CharacterClasses characterClasses;
+
+ public NGramSearcher(Linguistics linguistics) {
+ gramSplitter= linguistics.getGramSplitter();
+ characterClasses= linguistics.getCharacterClasses();
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ IndexFacts indexFacts = execution.context().getIndexFacts();
+ if ( ! indexFacts.hasNGramIndices()) return execution.search(query); // shortcut
+
+ IndexFacts.Session session = indexFacts.newSession(query);
+ boolean rewritten = rewriteToNGramMatching(query.getModel().getQueryTree().getRoot(), 0, session, query);
+ if (rewritten)
+ query.trace("Rewritten to n-gram matching",true,2);
+
+ Result result=execution.search(query);
+ recombineNGrams(result.hits().deepIterator(), session);
+ return result;
+ }
+
+ @Override
+ public void fill(Result result, String summaryClass, Execution execution) {
+ execution.fill(result, summaryClass);
+ IndexFacts indexFacts = execution.context().getIndexFacts();
+ if (indexFacts.hasNGramIndices())
+ recombineNGrams(result.hits().deepIterator(), indexFacts.newSession(result.getQuery()));
+ }
+
+ private boolean rewriteToNGramMatching(Item item, int indexInParent, IndexFacts.Session indexFacts, Query query) {
+ boolean rewritten = false;
+ if (item instanceof SegmentItem) { // handle CJK segmented terms which should be grams instead
+ SegmentItem segments = (SegmentItem)item;
+ Index index = indexFacts.getIndex(segments.getIndexName());
+ if (index.isNGram()) {
+ Item grams = splitToGrams(segments, toLowerCase(segments.getRawWord()), index.getGramSize(), query);
+ replaceItemByGrams(item, grams, indexInParent);
+ rewritten = true;
+ }
+ }
+ else if (item instanceof CompositeItem) {
+ CompositeItem composite = (CompositeItem)item;
+ for (int i=0; i<composite.getItemCount(); i++)
+ rewritten = rewriteToNGramMatching(composite.getItem(i), i, indexFacts, query) || rewritten;
+ }
+ else if (item instanceof TermItem) {
+ TermItem term = (TermItem)item;
+ Index index = indexFacts.getIndex(term.getIndexName());
+ if (index.isNGram()) {
+ Item grams = splitToGrams(term,term.stringValue(), index.getGramSize(), query);
+ replaceItemByGrams(item, grams, indexInParent);
+ rewritten = true;
+ }
+ }
+ return rewritten;
+ }
+
+ /**
+ * Splits the given item into n-grams and adds them as a CompositeItem containing WordItems searching the
+ * index of the input term. If the result is a single gram, that single WordItem is returned rather than the AndItem
+ *
+ * @param term the term to split, must be an item which implement the IndexedItem and BlockItem "mixins"
+ * @param text the text of the item, just stringValue() if the item is a TermItem
+ * @param gramSize the gram size to split to
+ * @param query the query in which this rewriting is done
+ * @return the root of the query subtree produced by this, containing the split items
+ */
+ protected Item splitToGrams(Item term, String text, int gramSize, Query query) {
+ CompositeItem and = createGramRoot(query);
+ String index = ((HasIndexItem)term).getIndexName();
+ Substring origin = ((BlockItem)term).getOrigin();
+ for (Iterator<GramSplitter.Gram> i = getGramSplitter().split(text,gramSize); i.hasNext(); ) {
+ GramSplitter.Gram gram = i.next();
+ WordItem gramWord = new WordItem(gram.extractFrom(text), index, false, origin);
+ gramWord.setWeight(term.getWeight());
+ gramWord.setProtected(true);
+ and.addItem(gramWord);
+ }
+ return and.getItemCount()==1 ? and.getItem(0) : and; // return the AndItem, or just the single gram if not multiple
+ }
+
+ /**
+ * Returns the (thread-safe) object to use to split the query text into grams.
+ */
+ protected final GramSplitter getGramSplitter() { return gramSplitter; }
+
+ /**
+ * Creates the root of the query subtree which will contain the grams to match,
+ * called by {@link #splitToGrams}. This hook is provided to make it easy to create a subclass which
+ * matches grams using a different composite item, e.g an OrItem.
+ * <p>
+ * This default implementation return new AndItem();
+ *
+ * @param query the input query, to make it possible to return a different composite item type
+ * depending on the query content
+ * @return the composite item to add the gram items to in {@link #splitToGrams}
+ */
+ protected CompositeItem createGramRoot(Query query) {
+ return new AndItem();
+ }
+
+ private void replaceItemByGrams(Item item, Item grams, int indexInParent) {
+ if (!(grams instanceof CompositeItem) || !(item.getParent() instanceof PhraseItem)) { // usually, simply replace
+ item.getParent().setItem(indexInParent, grams);
+ }
+ else { // but if the parent is a phrase, we cannot add the AND to it, so add each gram to the phrase
+ PhraseItem phraseParent = (PhraseItem)item.getParent();
+ phraseParent.removeItem(indexInParent);
+ int addedTerms = 0;
+ for (Iterator<Item> i = ((CompositeItem)grams).getItemIterator(); i.hasNext(); ) {
+ phraseParent.addItem(indexInParent+(addedTerms++),i.next());
+ }
+ }
+ }
+
+ private void recombineNGrams(Iterator<Hit> hits, IndexFacts.Session session) {
+ while (hits.hasNext()) {
+ Hit hit = hits.next();
+ if (hit.isMeta()) continue;
+ Object sddocname = hit.getField(Hit.SDDOCNAME_FIELD);
+ if (sddocname == null) return;
+ for (String fieldName : hit.fieldKeys()) {
+ Index index = session.getIndex(fieldName, sddocname.toString());
+ if (index.isNGram() && (index.getHighlightSummary() || index.getDynamicSummary())) {
+ hit.setField(fieldName, recombineNGramsField(hit.getField(fieldName), index.getGramSize()));
+ }
+ }
+ }
+ }
+
+ private Object recombineNGramsField(Object fieldValue,int gramSize) {
+ String recombined=recombineNGrams(fieldValue.toString(),gramSize);
+ if (fieldValue instanceof JSONString)
+ return new JSONString(recombined);
+ else if (fieldValue instanceof XMLString)
+ return new XMLString(recombined);
+ else
+ return recombined;
+ }
+
+ /**
+ * Converts grams to the original string.
+ *
+ * Example (gram size 3): <code>blulue rededs</code> becomes <code>blue reds</code>
+ */
+ private String recombineNGrams(final String string,final int gramSize) {
+ StringBuilder b=new StringBuilder();
+ int consecutiveWordChars=0;
+ boolean inBolding=false;
+ MatchTokenStrippingCharacterIterator characters=new MatchTokenStrippingCharacterIterator(string);
+ while (characters.hasNext()) {
+ char c=characters.next();
+ boolean atBoldingSeparator = (c=='\u001f');
+
+ if (atBoldingSeparator && characters.peek()=='\u001f') {
+ characters.next();
+ }
+ else if ( ! characterClasses.isLetterOrDigit(c)) {
+ if (atBoldingSeparator)
+ inBolding=!inBolding;
+ if ( ! (atBoldingSeparator && nextIsLetterOrDigit(characters)))
+ consecutiveWordChars=0;
+ if (inBolding && atBoldingSeparator && areWordCharactersBackwards(gramSize-1,b)) {
+ // we are going to skip characters from a gram, so move bolding start earlier
+ b.insert(b.length()-(gramSize-1),c);
+ }
+ else {
+ b.append(c);
+ }
+ }
+ else {
+ consecutiveWordChars++;
+ if (consecutiveWordChars<gramSize || (consecutiveWordChars % gramSize)==0)
+ b.append(c);
+ }
+ }
+ return b.toString();
+ }
+
+ private boolean areWordCharactersBackwards(int count,StringBuilder b) {
+ for (int i=0; i<count; i++) {
+ int checkIndex=b.length()-1-i;
+ if (checkIndex<0) return false;
+ if ( ! characterClasses.isLetterOrDigit(b.charAt(checkIndex))) return false;
+ }
+ return true;
+ }
+
+ private boolean nextIsLetterOrDigit(MatchTokenStrippingCharacterIterator characters) {
+ return characterClasses.isLetterOrDigit(characters.peek());
+ }
+
+ /**
+ * A string wrapper which skips match token forms marked up Juniper style, such that
+ * \uFFF9originalToken\uFFFAtoken\uFFFB is returned as originalToken
+ */
+ private static class MatchTokenStrippingCharacterIterator {
+
+ private final String s;
+ private int current =0;
+
+ public MatchTokenStrippingCharacterIterator(String s) {
+ this.s=s;
+ }
+
+ public boolean hasNext() {
+ skipMarkup();
+ return current <s.length();
+ }
+
+ public char next() {
+ skipMarkup();
+ return s.charAt(current++);
+ }
+
+ /** Returns the next character without moving to it. Returns \uFFFF if there is no next */
+ public char peek() {
+ skipMarkup();
+ if (s.length()< current +1)
+ return '\uFFFF';
+ else
+ return s.charAt(current);
+ }
+
+ private void skipMarkup() {
+ if (current>=s.length()) return;
+ char c=s.charAt(current);
+ if (c== AnnotateStringFieldPart.RAW_ANNOTATE_BEGIN_CHAR) { // skip it
+ current++;
+ }
+ else if (c==AnnotateStringFieldPart.RAW_ANNOTATE_SEPARATOR_CHAR) { // skip to RAW_ANNOTATE_END_CHAR
+ do {
+ current++;
+ } while (current<s.length() && s.charAt(current)!=AnnotateStringFieldPart.RAW_ANNOTATE_END_CHAR);
+ current++; // also skip the RAW_ANNOTATE_END_CHAR
+ skipMarkup(); // skip any immediately following markup
+ }
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/QueryCombinator.java b/container-search/src/main/java/com/yahoo/search/querytransform/QueryCombinator.java
new file mode 100644
index 00000000000..3a209a58f4a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/QueryCombinator.java
@@ -0,0 +1,155 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.language.Language;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.Index;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.parser.CustomParser;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.query.Properties;
+import com.yahoo.search.query.QueryTree;
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.IndexedItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.NullItem;
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.query.parser.ParserEnvironment;
+import com.yahoo.search.query.parser.ParserFactory;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * <p>A searcher which grabs query parameters of the form "defidx.(identifier)=(index name)" and
+ * "query.(identifier)=(user query)", * parses them and adds them as AND items to the query root.</p>
+ *
+ * <p>If the given default index does not exist in the search definition, the query part will be parsed with the
+ * settings of the default index set to the "".</p>
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class QueryCombinator extends Searcher {
+ private static final String QUERYPREFIX = "query.";
+
+ private static class QueryPart {
+ final String query;
+ final String defaultIndex;
+
+ QueryPart(String query, String defaultIndex) {
+ this.query = query;
+ this.defaultIndex = defaultIndex;
+ }
+ }
+
+ public QueryCombinator(ComponentId id) {
+ super(id);
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ Set<QueryPart> pieces = findQuerySnippets(query.properties());
+ if (pieces.size() == 0) {
+ return execution.search(query);
+ }
+ addAndItems(query, pieces, execution.context());
+ query.trace("Adding extra query parts.", true, 2);
+ return execution.search(query);
+ }
+
+ private void addAndItems(Query query, Iterable<QueryPart> pieces, Execution.Context context) {
+ IndexFacts indexFacts = context.getIndexFacts();
+ IndexFacts.Session session = indexFacts.newSession(query);
+ Set<String> usedSources = new HashSet<>(session.documentTypes());
+ Language language = query.getModel().getParsingLanguage();
+ for (QueryPart part : pieces) {
+ String defaultIndex;
+ Item item = null;
+ Index index = session.getIndex(part.defaultIndex);
+ if (index == Index.nullIndex) {
+ defaultIndex = null;
+ } else {
+ defaultIndex = part.defaultIndex;
+ }
+ try {
+ CustomParser parser = (CustomParser)ParserFactory.newInstance(query.getModel().getType(),
+ ParserEnvironment.fromExecutionContext(context));
+ item = parser.parse(part.query, null, language, usedSources, indexFacts, defaultIndex);
+ } catch (RuntimeException e) {
+ String err = Exceptions.toMessageString(e);
+ query.trace("Query parser threw an exception: " + err, true, 1);
+ getLogger().log(LogLevel.WARNING,
+ "Query parser threw exception searcher QueryCombinator for "
+ + query.getHttpRequest().toString() + ", query part " + part.query + ": " + err);
+ }
+ if (item == null) {
+ continue;
+ }
+ if (defaultIndex == null) {
+ assignDefaultIndex(item, part.defaultIndex);
+ }
+ addAndItem(query.getModel().getQueryTree(), item);
+ }
+ }
+
+ private static void addAndItem(QueryTree queryTree, Item item) {
+ Item root = queryTree.getRoot();
+ // JavaDoc claims I can get null, code gives NullItem... well, well, well...
+ if (root instanceof NullItem || root == null) {
+ queryTree.setRoot(item);
+ } else if (root.getClass() == AndItem.class) {
+ ((AndItem) root).addItem(item);
+ } else {
+ AndItem newRoot = new AndItem();
+ newRoot.addItem(root);
+ newRoot.addItem(item);
+ queryTree.setRoot(newRoot);
+ }
+ }
+
+ private static void assignDefaultIndex(Item item, String defaultIndex) {
+ if (item instanceof IndexedItem) {
+ IndexedItem indexName = (IndexedItem) item;
+
+ if ("".equals(indexName.getIndexName())) {
+ indexName.setIndexName(defaultIndex);
+ }
+ } else if (item instanceof CompositeItem) {
+ Iterator<Item> items = ((CompositeItem) item).getItemIterator();
+ while (items.hasNext()) {
+ Item i = items.next();
+ assignDefaultIndex(i, defaultIndex);
+ }
+ }
+ }
+
+ private static Set<QueryPart> findQuerySnippets(Properties properties) {
+ Set<QueryPart> pieces = new HashSet<>();
+ for (Map.Entry<String, Object> k : properties.listProperties().entrySet()) {
+ String key = k.getKey();
+ if (!key.startsWith(QUERYPREFIX)) {
+ continue;
+ }
+ String name = key.substring(QUERYPREFIX.length());
+ if (hasDots(name)) {
+ continue;
+ }
+ String index = properties.getString("defidx." + name);
+ pieces.add(new QueryPart(k.getValue().toString(), index));
+ }
+ return pieces;
+ }
+
+ private static boolean hasDots(String name) {
+ int index = name.indexOf('.', 0);
+ return index != -1;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/QueryTreeUtil.java b/container-search/src/main/java/com/yahoo/search/querytransform/QueryTreeUtil.java
new file mode 100644
index 00000000000..fb5373d59ea
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/QueryTreeUtil.java
@@ -0,0 +1,35 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.search.Query;
+import com.yahoo.search.query.QueryTree;
+
+/**
+ * Utility class for manipulating a QueryTree.
+ *
+ * @author <a href="mailto:geirst@yahoo-inc.com">Geir Storli</a>
+ */
+public class QueryTreeUtil {
+
+ static public void andQueryItemWithRoot(Query query, Item item) {
+ andQueryItemWithRoot(query.getModel().getQueryTree(), item);
+ }
+
+ static public void andQueryItemWithRoot(QueryTree tree, Item item) {
+ if (tree.isEmpty()) {
+ tree.setRoot(item);
+ } else {
+ Item oldRoot = tree.getRoot();
+ if (oldRoot.getClass() == AndItem.class) {
+ ((AndItem) oldRoot).addItem(item);
+ } else {
+ AndItem newRoot = new AndItem();
+ newRoot.addItem(oldRoot);
+ newRoot.addItem(item);
+ tree.setRoot(newRoot);
+ }
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/RangeQueryOptimizer.java b/container-search/src/main/java/com/yahoo/search/querytransform/RangeQueryOptimizer.java
new file mode 100644
index 00000000000..65832d99461
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/RangeQueryOptimizer.java
@@ -0,0 +1,212 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.prelude.query.Limit;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.FalseItem;
+import com.yahoo.prelude.query.IntItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.QueryCanonicalizer;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.PhaseNames;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Before;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Finds and optimizes ranges in queries:
+ * For single value attributes c1 $lt; x AND x &gt; c2 becomes x IN &lt;c1; c2&gt;.
+ * The query cost saving from this has been shown to be 2 orders of magnitude in real cases.
+ *
+ * @author bratseth
+ */
+@Before(QueryCanonicalizer.queryCanonicalization)
+@After(PhaseNames.TRANSFORMED_QUERY)
+public class RangeQueryOptimizer extends Searcher {
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ if (execution.context().getIndexFacts() == null) return execution.search(query); // this is a test query
+
+ boolean optimized = recursiveOptimize(query.getModel().getQueryTree(), execution.context().getIndexFacts().newSession(query));
+ if (optimized)
+ query.trace("Optimized query ranges", true, 2);
+ return execution.search(query);
+ }
+
+ /** Recursively performs the range optimization on this query tree and returns whether at least one optimization was done */
+ private boolean recursiveOptimize(Item item, IndexFacts.Session indexFacts) {
+ if ( ! (item instanceof CompositeItem)) return false;
+
+ boolean optimized = false;
+ for (Iterator<Item> i = ((CompositeItem) item).getItemIterator(); i.hasNext(); )
+ optimized |= recursiveOptimize(i.next(), indexFacts);
+
+ if (item instanceof AndItem)
+ optimized |= optimizeAnd((AndItem)item, indexFacts);
+ return optimized;
+ }
+
+ private boolean optimizeAnd(AndItem and, IndexFacts.Session indexFacts) {
+ // Find consolidated ranges by collecting a list of compatible ranges
+ List<FieldRange> fieldRanges = null;
+ for (Iterator<Item> i = and.getItemIterator(); i.hasNext(); ) {
+ Item item = i.next();
+ if ( ! (item instanceof IntItem)) continue;
+ IntItem intItem = (IntItem)item;
+ if (intItem.getHitLimit() != 0) continue; // each such op gets a different partial set: Cannot be optimized
+ if (intItem.getFromLimit().equals(intItem.getToLimit())) continue; // don't optimize searches for single numbers
+ if (indexFacts.getIndex(intItem.getIndexName()).isMultivalue()) continue; // May match different values in each range
+
+ if (fieldRanges == null) fieldRanges = new ArrayList<>();
+ Optional<FieldRange> compatibleRange = findCompatibleRange(intItem, fieldRanges);
+ if (compatibleRange.isPresent())
+ compatibleRange.get().addRange(intItem);
+ else
+ fieldRanges.add(new FieldRange(intItem));
+ i.remove();
+ }
+
+ // Add consolidated ranges
+ if (fieldRanges == null) return false;
+
+ boolean optimized = false;
+ for (FieldRange fieldRange : fieldRanges) {
+ and.addItem(fieldRange.toItem());
+ optimized |= fieldRange.isOptimization();
+ }
+ return optimized;
+ }
+
+ private Optional<FieldRange> findCompatibleRange(IntItem item, List<FieldRange> fieldRanges) {
+ for (FieldRange fieldRange : fieldRanges) {
+ if (fieldRange.isCompatibleWith(item))
+ return Optional.of(fieldRange);
+ }
+ return Optional.empty();
+ }
+
+ /** Represents the ranges searched in a single field */
+ private static final class FieldRange {
+
+ private Range range = new Range(new Limit(Double.NEGATIVE_INFINITY, false), new Limit(Double.POSITIVE_INFINITY, false));
+ private int sourceRangeCount = 0;
+
+ // IntItem fields which must be preserved in the produced item.
+ // This is an unfortunate coupling and ideally we should delegate this (creation, compatibility)
+ // to the Item classes
+ private final String indexName;
+ private final Item.ItemCreator creator;
+ private final boolean ranked;
+ private final int weight;
+
+ public FieldRange(IntItem item) {
+ this.indexName = item.getIndexName();
+ this.creator = item.getCreator();
+ this.ranked = item.isRanked();
+ this.weight = item.getWeight();
+ addRange(item);
+ }
+
+ public String getIndexName() { return indexName; }
+
+ public boolean isCompatibleWith(IntItem item) {
+ if ( ! indexName.equals(item.getIndexName())) return false;
+ if (creator != item.getCreator()) return false;
+ if (ranked != item.isRanked()) return false;
+ if (weight != item.getWeight()) return false;
+ return true;
+ }
+
+ /** Adds a range for this field */
+ public void addRange(IntItem item) {
+ range = range.intersection(new Range(item));
+ sourceRangeCount++;
+ }
+
+ public Item toItem() {
+ Item item = range.toItem(indexName);
+ item.setCreator(creator);
+ item.setRanked(ranked);
+ item.setWeight(weight);
+ return item;
+ }
+
+ /** Returns whether this range is actually an optimization over what was in the source query */
+ public boolean isOptimization() { return sourceRangeCount > 1; }
+
+ }
+
+ /** An immutable numerical range */
+ private static class Range {
+
+ private final Limit from;
+ private final Limit to;
+
+ private static final Range empty = new EmptyRange();
+
+ public Range(Limit from, Limit to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ public Range(IntItem range) {
+ from = range.getFromLimit();
+ to = range.getToLimit();
+ }
+
+ /** Returns true if these two ranges overlap */
+ public boolean overlaps(Range other) {
+ if (other.from.isSmallerOrEqualTo(this.to) && other.to.isLargerOrEqualTo(this.from)) return true;
+ if (other.to.isLargerOrEqualTo(this.from) && other.from.isSmallerOrEqualTo(this.to)) return true;
+ return false;
+ }
+
+ /**
+ * Returns the intersection of this and the given range.
+ * If the ranges does not overlap, an empty range is returned.
+ */
+ public Range intersection(Range other) {
+ if ( ! overlaps(other)) return empty;
+ return new Range(from.max(other.from), to.min(other.to));
+ }
+
+ public Item toItem(String fieldName) {
+ return IntItem.from(fieldName, from, to, 0);
+ }
+
+ @Override
+ public String toString() { return "[" + from + ";" + to + "]"; }
+
+ }
+
+ private static class EmptyRange extends Range {
+
+ public EmptyRange() {
+ super(new Limit(0, false), new Limit(0, false)); // the to and from of an empty range is never used.
+ }
+
+ @Override
+ public boolean overlaps(Range other) { return false; }
+
+ @Override
+ public Range intersection(Range other) { return this; }
+
+ @Override
+ public Item toItem(String fieldName) { return new FalseItem(); }
+
+ @Override
+ public String toString() { return "(empty)"; }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/SortingDegrader.java b/container-search/src/main/java/com/yahoo/search/querytransform/SortingDegrader.java
new file mode 100644
index 00000000000..5886014deed
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/SortingDegrader.java
@@ -0,0 +1,105 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.prelude.Index;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.QueryCanonicalizer;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.grouping.GroupingQueryParser;
+import com.yahoo.search.grouping.GroupingRequest;
+import com.yahoo.search.query.Sorting;
+import com.yahoo.search.query.properties.DefaultProperties;
+import com.yahoo.search.query.ranking.MatchPhase;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Before;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * If the query is eligible, specify that the query should degrade if it causes too many hits
+ * to avoid excessively expensive queries.
+ * <p>
+ * Queries are eligible if they do sorting, don't do grouping, and the first sort criteria is a fast-search attribute.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+
+// This writes fields to query.getRanking which are moved to rank.properties during query.prepare()
+// Query.prepare is done at the same time as canonicalization (by GroupingExecutor), so use that constraint.
+// (we're not adding another constraint at this point because all this preparation and encoding business
+// should be fixed when we move to Slime for serialization. - Jon, in the spring of the year of 2014)
+@Before(QueryCanonicalizer.queryCanonicalization)
+
+// We are checking if there is a grouping expression, not if there is a raw grouping instruction property,
+// so we must run after the property is transferred to a grouping expression
+@After(GroupingQueryParser.SELECT_PARAMETER_PARSING)
+
+public class SortingDegrader extends Searcher {
+
+ /** Set this to false in query.properties to turn off degrading. Default: on */
+ // (this is not called ranking.sorting.degrading because it should not be part of the query object model
+ public static final CompoundName DEGRADING = new CompoundName("sorting.degrading");
+
+ public static final CompoundName PAGINATION = new CompoundName("to_be_removed_pagination");
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ if (shouldBeDegraded(query, execution.context().getIndexFacts().newSession(query)))
+ setDegradation(query);
+ return execution.search(query);
+ }
+
+ private boolean shouldBeDegraded(Query query, IndexFacts.Session indexFacts) {
+ if (query.getRanking().getSorting() == null) return false;
+ if (query.getRanking().getSorting().fieldOrders().isEmpty()) return false;
+ if ( ! GroupingRequest.getRequests(query).isEmpty()) return false;
+ if ( ! query.properties().getBoolean(DEGRADING, true)) return false;
+
+ Index index = indexFacts.getIndex(query.getRanking().getSorting().fieldOrders().get(0).getFieldName());
+ if (index == null) return false;
+ if ( ! index.isFastSearch()) return false;
+ if ( ! index.isNumerical()) return false;
+
+ return true;
+ }
+
+ private void setDegradation(Query query) {
+ Sorting.FieldOrder primarySort = query.getRanking().getSorting().fieldOrders().get(0); // ensured above
+ MatchPhase matchPhase = query.getRanking().getMatchPhase();
+
+ matchPhase.setAttribute(primarySort.getFieldName());
+ matchPhase.setAscending(primarySort.getSortOrder() == Sorting.Order.ASCENDING);
+ if (matchPhase.getMaxHits() == null)
+ matchPhase.setMaxHits(decideDefaultMaxHits(query));
+ }
+
+ /**
+ * Look at a "reasonable" number of this by default. We don't want to set this too low because it impacts
+ * the totalHits value returned.
+ * <p>
+ * If maxhits/offset is set high, use that as the default instead because it means somebody will want to be able to
+ * get lots of hits. We could use hits+offset instead of maxhits+maxoffset but that would destroy pagination
+ * with large values because totalHits is wrong.
+ * <p>
+ * If we ever get around to estimate totalhits we can rethink this.
+ */
+ private long decideDefaultMaxHits(Query query) {
+ int maxHits;
+ int maxOffset;
+ if (query.properties().getBoolean(PAGINATION, true)) {
+ maxHits = query.properties().getInteger(DefaultProperties.MAX_HITS);
+ maxOffset = query.properties().getInteger(DefaultProperties.MAX_OFFSET);
+ } else {
+ maxHits = query.getHits();
+ maxOffset = query.getOffset();
+ }
+ return maxHits + maxOffset;
+ }
+
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/VespaLowercasingSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/VespaLowercasingSearcher.java
new file mode 100644
index 00000000000..2e8e0861656
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/VespaLowercasingSearcher.java
@@ -0,0 +1,39 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import static com.yahoo.prelude.querytransform.NormalizingSearcher.ACCENT_REMOVAL;
+import static com.yahoo.prelude.querytransform.StemmingSearcher.STEMMING;
+
+import java.util.Collection;
+
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Provides;
+import com.yahoo.prelude.Index;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.WordItem;
+
+/**
+ * Transform terms in query tree to lower case based on Vespa index settings.
+ *
+ * @since 5.1.3
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@After({ STEMMING, ACCENT_REMOVAL })
+@Provides(VespaLowercasingSearcher.LOWERCASING)
+public class VespaLowercasingSearcher extends LowercasingSearcher {
+
+ public static final String LOWERCASING = "LowerCasing";
+
+ public VespaLowercasingSearcher(LowercasingConfig cfg) {
+ super(cfg);
+ }
+
+ @Override
+ public boolean shouldLowercase(WordItem word, IndexFacts.Session indexFacts) {
+ if (word.isLowercased()) return false;
+
+ Index index = indexFacts.getIndex(word.getIndexName());
+ return index.isLowercase() || index.isAttribute();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/WandSearcher.java b/container-search/src/main/java/com/yahoo/search/querytransform/WandSearcher.java
new file mode 100644
index 00000000000..6120a7aee30
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/WandSearcher.java
@@ -0,0 +1,206 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.querytransform;
+
+import com.yahoo.prelude.Index;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.*;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.text.MapParser;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static com.yahoo.container.protect.Error.UNSPECIFIED;
+import com.yahoo.yolean.Exceptions;
+
+/**
+ * Searcher that will create a Vespa WAND item from a list of tokens with weights.
+ * IndexFacts is used to determine which WAND to create.
+ *
+ * @since 5.1.11
+ * @author <a href="mailto:geirst@yahoo-inc.com">Geir Storli</a>
+ * @author bratseth
+ */
+public class WandSearcher extends Searcher {
+
+ /**
+ * Enum used to represent which "wand" this searcher should produce.
+ */
+ private enum WandType {
+ VESPA("vespa"),
+ OR("or"),
+ PARALLEL("parallel"),
+ DOT_PRODUCT("dotProduct");
+
+ private final String type;
+
+ WandType(String type) {
+ this.type = type;
+ }
+
+ public static WandType create(String type) {
+ for (WandType enumType : WandType.values()) {
+ if (enumType.type.equals(type)) {
+ return enumType;
+ }
+ }
+ return WandType.VESPA;
+ }
+ }
+
+ /**
+ * Class to resolve the inputs used by this searcher.
+ */
+ private static class InputResolver {
+
+ private static final CompoundName WAND_FIELD = new CompoundName("wand.field");
+ private static final CompoundName WAND_TOKENS = new CompoundName("wand.tokens");
+ private static final CompoundName WAND_HEAP_SIZE = new CompoundName("wand.heapSize");
+ private static final CompoundName WAND_TYPE = new CompoundName("wand.type");
+ private static final CompoundName WAND_SCORE_THRESHOLD = new CompoundName("wand.scoreThreshold");
+ private static final CompoundName WAND_THRESHOLD_BOOST_FACTOR = new CompoundName("wand.thresholdBoostFactor");
+ private final String fieldName;
+ private final WandType wandType;
+ private final Map<String, Integer> tokens;
+ private final int heapSize;
+ private final double scoreThreshold;
+ private final double thresholdBoostFactor;
+
+ public InputResolver(Query query, Execution execution) {
+ fieldName = query.properties().getString(WAND_FIELD);
+ if (fieldName != null) {
+ String tokens = query.properties().getString(WAND_TOKENS);
+ if (tokens != null) {
+ wandType = resolveWandType(execution.context().getIndexFacts().newSession(query), query);
+ this.tokens = new IntegerMapParser().parse(tokens, new LinkedHashMap<>());
+ heapSize = resolveHeapSize(query);
+ scoreThreshold = resolveScoreThreshold(query);
+ thresholdBoostFactor = resolveThresholdBoostFactor(query);
+ return;
+ }
+ }
+ wandType = null;
+ tokens = null;
+ heapSize = 0;
+ scoreThreshold = 0;
+ thresholdBoostFactor = 1;
+ }
+
+ private WandType resolveWandType(IndexFacts.Session indexFacts, Query query) {
+ Index index = indexFacts.getIndex(fieldName);
+ if (index.isNull()) {
+ throw new IllegalArgumentException("Field '" + fieldName + "' was not found in " + indexFacts);
+ } else {
+ return WandType.create(query.properties().getString(WAND_TYPE, "vespa"));
+ }
+ }
+
+ private int resolveHeapSize(Query query) {
+ String defaultHeapSize = "100";
+ return Integer.valueOf(query.properties().getString(WAND_HEAP_SIZE, defaultHeapSize));
+ }
+
+ private double resolveScoreThreshold(Query query) {
+ return Double.valueOf(query.properties().getString(WAND_SCORE_THRESHOLD, "0"));
+ }
+
+ private double resolveThresholdBoostFactor(Query query) {
+ return Double.valueOf(query.properties().getString(WAND_THRESHOLD_BOOST_FACTOR, "1"));
+ }
+
+ public boolean hasValidData() {
+ return tokens != null && !tokens.isEmpty();
+ }
+
+ public String getFieldName() {
+ return fieldName;
+ }
+
+ public Map<String, Integer> getTokens() {
+ return tokens;
+ }
+
+ public WandType getWandType() {
+ return wandType;
+ }
+
+ public Integer getHeapSize() {
+ return heapSize;
+ }
+
+ public Double getScoreThreshold() {
+ return scoreThreshold;
+ }
+
+ public Double getThresholdBoostFactor() {
+ return thresholdBoostFactor;
+ }
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ try {
+ InputResolver inputs = new InputResolver(query, execution);
+ if ( ! inputs.hasValidData()) return execution.search(query);
+
+ QueryTreeUtil.andQueryItemWithRoot(query, createWandQueryItem(inputs));
+ query.trace("WandSearcher: Added WAND operator", true, 4);
+ return execution.search(query);
+ }
+ catch (IllegalArgumentException e) {
+ return new Result(query,ErrorMessage.createInvalidQueryParameter(Exceptions.toMessageString(e)));
+ }
+ }
+
+ private Item createWandQueryItem(InputResolver inputs) {
+ if (inputs.getWandType().equals(WandType.VESPA)) {
+ return populate(new WeakAndItem(inputs.getHeapSize()), inputs.getFieldName(), inputs.getTokens());
+ } else if (inputs.getWandType().equals(WandType.OR)) {
+ return populate(new OrItem(), inputs.getFieldName(), inputs.getTokens());
+ } else if (inputs.getWandType().equals(WandType.PARALLEL)) {
+ return populate(new WandItem(inputs.getFieldName(), inputs.getHeapSize()),
+ inputs.getScoreThreshold(), inputs.getThresholdBoostFactor(), inputs.getTokens());
+ } else if (inputs.getWandType().equals(WandType.DOT_PRODUCT)) {
+ return populate(new DotProductItem(inputs.getFieldName()), inputs.getTokens());
+ }
+ throw new IllegalArgumentException("Unknown type '" + inputs.getWandType() + "'");
+ }
+
+ private CompositeItem populate(CompositeItem parent, String fieldName, Map<String,Integer> tokens) {
+ for (Map.Entry<String,Integer> entry : tokens.entrySet()) {
+ WordItem wordItem = new WordItem(entry.getKey(), fieldName);
+ wordItem.setWeight(entry.getValue());
+ wordItem.setStemmed(true);
+ wordItem.setNormalizable(false);
+ parent.addItem(wordItem);
+ }
+ return parent;
+ }
+
+ private WeightedSetItem populate(WeightedSetItem item, Map<String,Integer> tokens) {
+ for (Map.Entry<String,Integer> entry : tokens.entrySet()) {
+ item.addToken(entry.getKey(), entry.getValue());
+ }
+ return item;
+ }
+
+ private WandItem populate(WandItem item, double scoreThreshold, double thresholdBoostFactor, Map<String,Integer> tokens) {
+ populate(item, tokens);
+ item.setScoreThreshold(scoreThreshold);
+ item.setThresholdBoostFactor(thresholdBoostFactor);
+ return item;
+ }
+
+ private static class IntegerMapParser extends MapParser<Integer> {
+ @Override
+ protected Integer parseValue(String s) {
+ return Integer.parseInt(s);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/package-info.java b/container-search/src/main/java/com/yahoo/search/querytransform/package-info.java
new file mode 100644
index 00000000000..34e59301fca
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/package-info.java
@@ -0,0 +1,9 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Vespa search platform query transformation infrastructure. Not a public
+ * API.
+ */
+@ExportPackage
+package com.yahoo.search.querytransform;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/querytransform/parser/.gitignore b/container-search/src/main/java/com/yahoo/search/querytransform/parser/.gitignore
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/querytransform/parser/.gitignore
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/DefaultRenderer.java b/container-search/src/main/java/com/yahoo/search/rendering/DefaultRenderer.java
new file mode 100644
index 00000000000..de817d95393
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/DefaultRenderer.java
@@ -0,0 +1,450 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.rendering;
+
+import com.yahoo.concurrent.CopyOnWriteHashMap;
+import com.yahoo.io.ByteWriter;
+import com.yahoo.net.URI;
+import com.yahoo.prelude.fastsearch.FastHit;
+import com.yahoo.prelude.fastsearch.GroupingListHit;
+import com.yahoo.prelude.templates.UserTemplate;
+import com.yahoo.processing.rendering.AsynchronousSectionedRenderer;
+import com.yahoo.processing.response.Data;
+import com.yahoo.processing.response.DataList;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.result.HitRenderer;
+import com.yahoo.search.query.context.QueryContext;
+import com.yahoo.search.result.*;
+import com.yahoo.text.Utf8String;
+import com.yahoo.text.XMLWriter;
+import com.yahoo.yolean.trace.TraceNode;
+import com.yahoo.yolean.trace.TraceVisitor;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+import java.util.Iterator;
+import java.util.Map;
+
+// TODO: Rename to XmlRenderer and make this a deprecated empty subclass.
+
+/**
+ * XML rendering of search results. This is NOT the default (but it once was).
+ *
+ * @author tonytv
+ */
+@SuppressWarnings({ "rawtypes", "deprecation" })
+public final class DefaultRenderer extends AsynchronousSectionedRenderer<Result> {
+
+ public static final String DEFAULT_MIMETYPE = "text/xml";
+ public static final String DEFAULT_ENCODING = "utf-8";
+
+ private static final Utf8String RESULT = new Utf8String("result");
+ private static final Utf8String GROUP = new Utf8String("group");
+ private static final Utf8String ID = new Utf8String("id");
+ private static final Utf8String FIELD = new Utf8String("field");
+ private static final Utf8String HIT = new Utf8String("hit");
+ private static final Utf8String ERROR = new Utf8String("error");
+ private static final Utf8String TOTAL_HIT_COUNT = new Utf8String("total-hit-count");
+ private static final Utf8String QUERY_TIME = new Utf8String("querytime");
+ private static final Utf8String SUMMARY_FETCH_TIME = new Utf8String("summaryfetchtime");
+ private static final Utf8String SEARCH_TIME = new Utf8String("searchtime");
+ private static final Utf8String NAME = new Utf8String("name");
+ private static final Utf8String CODE = new Utf8String("code");
+ private static final Utf8String COVERAGE_DOCS = new Utf8String("coverage-docs");
+ private static final Utf8String COVERAGE_NODES = new Utf8String("coverage-nodes");
+ private static final Utf8String COVERAGE_FULL = new Utf8String("coverage-full");
+ private static final Utf8String COVERAGE = new Utf8String("coverage");
+ private static final Utf8String RESULTS_FULL = new Utf8String("results-full");
+ private static final Utf8String RESULTS = new Utf8String("results");
+ private static final Utf8String TYPE = new Utf8String("type");
+ private static final Utf8String RELEVANCY = new Utf8String("relevancy");
+ private static final Utf8String SOURCE = new Utf8String("source");
+
+
+ // this is shared between umpteen threads by design
+ private final CopyOnWriteHashMap<String, Utf8String> fieldNameMap = new CopyOnWriteHashMap<>();
+
+ private boolean utf8Output = false;
+
+ private XMLWriter writer;
+
+ @Override
+ public void init() {
+ super.init();
+ utf8Output = false;
+ writer = null;
+ }
+
+ @Override
+ public String getEncoding() {
+
+ if (getResult() == null
+ || getResult().getQuery() == null
+ || getResult().getQuery().getModel().getEncoding() == null) {
+ return DEFAULT_ENCODING;
+ } else {
+ return getResult().getQuery().getModel().getEncoding();
+ }
+ }
+
+ @Override
+ public String getMimeType() {
+ return DEFAULT_MIMETYPE;
+ }
+
+ private XMLWriter wrapWriter(Writer writer) {
+ return XMLWriter.from(writer, 10, -1);
+ }
+
+ private void header(XMLWriter writer, Result result) throws IOException {
+ // TODO: move setting this to Result
+ utf8Output = "utf-8".equalsIgnoreCase(getRequestedEncoding(result.getQuery()));
+ writer.xmlHeader(getRequestedEncoding(result.getQuery()));
+ writer.openTag(RESULT).attribute(TOTAL_HIT_COUNT, String.valueOf(result.getTotalHitCount()));
+ if (result.getQuery().getPresentation().getReportCoverage()) {
+ renderCoverageAttributes(result.getCoverage(false), writer);
+ }
+ renderTime(writer, result);
+ writer.closeStartTag();
+ }
+
+ private void renderTime(XMLWriter writer, Result result) {
+ if (!result.getQuery().getPresentation().getTiming()) {
+ return;
+ }
+
+ final String threeDecimals = "%.3f";
+ final double milli = .001d;
+ final long now = System.currentTimeMillis();
+ final long searchTime = now - result.getElapsedTime().first();
+ final double searchSeconds = ((double) searchTime) * milli;
+
+ if (result.getElapsedTime().firstFill() != 0L) {
+ final long queryTime = result.getElapsedTime().weightedSearchTime();
+ final long summaryFetchTime = result.getElapsedTime().weightedFillTime();
+ final double querySeconds = ((double) queryTime) * milli;
+ final double summarySeconds = ((double) summaryFetchTime) * milli;
+ writer.attribute(QUERY_TIME, String.format(threeDecimals, querySeconds));
+ writer.attribute(SUMMARY_FETCH_TIME, String.format(threeDecimals, summarySeconds));
+ }
+ writer.attribute(SEARCH_TIME, String.format(threeDecimals, searchSeconds));
+ }
+
+ protected static void renderCoverageAttributes(Coverage coverage, XMLWriter writer) throws IOException {
+ if (coverage == null) return;
+ writer.attribute(COVERAGE_DOCS,coverage.getDocs());
+ writer.attribute(COVERAGE_NODES,coverage.getNodes());
+ writer.attribute(COVERAGE_FULL,coverage.getFull());
+ writer.attribute(COVERAGE,coverage.getResultPercentage());
+ writer.attribute(RESULTS_FULL,coverage.getFullResultSets());
+ writer.attribute(RESULTS,coverage.getResultSets());
+ }
+
+
+ public void error(XMLWriter writer, Result result) throws IOException {
+ ErrorMessage error = result.hits().getError();
+ writer.openTag(ERROR).attribute(CODE,error.getCode()).content(error.getMessage(),false).closeTag();
+ }
+
+
+ @SuppressWarnings("UnusedParameters")
+ protected void emptyResult(XMLWriter writer, Result result) throws IOException {}
+
+ @SuppressWarnings("UnusedParameters")
+ public void queryContext(XMLWriter writer, QueryContext queryContext, Query owner) throws IOException {
+ if (owner.getTraceLevel()!=0) {
+ XMLWriter xmlWriter=XMLWriter.from(writer);
+ xmlWriter.openTag("meta").attribute("type", QueryContext.ID);
+ TraceNode traceRoot = owner.getModel().getExecution().trace().traceNode().root();
+ traceRoot.accept(new RenderingVisitor(xmlWriter, owner.getStartTime()));
+ xmlWriter.closeTag();
+ }
+ }
+
+
+ private void renderSingularHit(XMLWriter writer, Hit hit) throws IOException {
+ writer.openTag(HIT);
+ renderHitAttributes(writer, hit);
+ writer.closeStartTag();
+ renderHitFields(writer, hit);
+ }
+
+ private void renderHitFields(XMLWriter writer, Hit hit) throws IOException {
+ renderSyntheticRelevanceField(writer, hit);
+ for (Iterator<Map.Entry<String, Object>> it = hit.fieldIterator(); it.hasNext(); ) {
+ renderField(writer, hit, it);
+ }
+ }
+
+ private void renderField(XMLWriter writer, Hit hit, Iterator<Map.Entry<String, Object>> it) throws IOException {
+ Map.Entry<String, Object> entry = it.next();
+ boolean isProbablyNotDecoded = false;
+ if (hit instanceof FastHit) {
+ FastHit f = (FastHit) hit;
+ isProbablyNotDecoded = f.fieldIsNotDecoded(entry.getKey());
+ }
+ renderGenericFieldPossiblyNotDecoded(writer, hit, entry, isProbablyNotDecoded);
+ }
+
+ private void renderGenericFieldPossiblyNotDecoded(XMLWriter writer, Hit hit, Map.Entry<String, Object> entry, boolean probablyNotDecoded) throws IOException {
+ String fieldName = entry.getKey();
+
+ // skip depending on hit type
+ if (fieldName.startsWith("$")) return; // Don't render fields that start with $ // TODO: Move to should render
+
+ writeOpenFieldElement(writer, fieldName);
+ renderFieldContentPossiblyNotDecoded(writer, hit, probablyNotDecoded, fieldName);
+ writeCloseFieldElement(writer);
+ }
+
+ private void renderFieldContentPossiblyNotDecoded(XMLWriter writer, Hit hit, boolean probablyNotDecoded, String fieldName) throws IOException {
+ boolean dumpedRaw = false;
+ if (probablyNotDecoded && (hit instanceof FastHit)) {
+ writer.closeStartTag();
+ if ((writer.getWriter() instanceof ByteWriter) && utf8Output) {
+ dumpedRaw = UserTemplate.dumpBytes((ByteWriter) writer.getWriter(), (FastHit) hit, fieldName);
+ }
+ if (dumpedRaw) {
+ writer.content("", false); // let the xml writer note that this tag had content
+ }
+ }
+ if (!dumpedRaw) {
+ String xmlval = hit.getFieldXML(fieldName);
+ if (xmlval == null) {
+ xmlval = "(null)";
+ }
+ writer.escapedContent(xmlval, false);
+ }
+ }
+
+ private void renderSyntheticRelevanceField(XMLWriter writer, Hit hit) throws IOException {
+ final String relevancyFieldName = "relevancy";
+ final Relevance relevance = hit.getRelevance();
+
+ // skip depending on hit type
+ if (relevance != null) {
+ renderSimpleField(writer, relevancyFieldName, relevance);
+ }
+ }
+
+ private void renderSimpleField(XMLWriter writer, String relevancyFieldName, Relevance relevance) throws IOException {
+ writeOpenFieldElement(writer, relevancyFieldName);
+ writer.content(relevance.toString(), false);
+ writeCloseFieldElement(writer);
+ }
+
+ private void writeCloseFieldElement(XMLWriter writer) throws IOException {
+ writer.closeTag();
+ }
+
+ private void writeOpenFieldElement(XMLWriter writer, String relevancyFieldName) throws IOException {
+ Utf8String utf8 = fieldNameMap.get(relevancyFieldName);
+ if (utf8 == null) {
+ utf8 = new Utf8String(relevancyFieldName);
+ fieldNameMap.put(relevancyFieldName, utf8);
+ }
+ writer.openTag(FIELD).attribute(NAME, utf8);
+ writer.closeStartTag();
+ }
+
+ private void renderHitAttributes(XMLWriter writer, Hit hit) throws IOException {
+ writer.attribute(TYPE, hit.getTypeString());
+ if (hit.getRelevance() != null) {
+ writer.attribute(RELEVANCY, hit.getRelevance().toString());
+}
+ writer.attribute(SOURCE, hit.getSource());
+ }
+
+ private void renderHitGroup(XMLWriter writer, HitGroup hit) throws IOException {
+ if (HitRenderer.renderHeader(hit, writer)) {
+ // empty
+ } else if (hit.types().contains("grouphit")) {
+ // TODO Keep this?
+ renderHitGroupOfTypeGroupHit(writer, hit);
+ } else {
+ renderGroup(writer, hit);
+ }
+ }
+
+ private void renderGroup(XMLWriter writer, HitGroup hit) throws IOException {
+ writer.openTag(GROUP);
+ renderHitAttributes(writer, hit);
+ writer.closeStartTag();
+ }
+
+ private void renderHitGroupOfTypeGroupHit(XMLWriter writer, HitGroup hit) throws IOException {
+ writer.openTag(HIT);
+ renderHitAttributes(writer, hit);
+ renderId(writer, hit);
+ writer.closeStartTag();
+ }
+
+ private void renderId(XMLWriter writer, HitGroup hit) throws IOException {
+ URI uri = hit.getId();
+ if (uri != null) {
+ writer.openTag(ID).content(uri.stringValue(),false).closeTag();
+ }
+ }
+
+ private boolean simpleRenderHit(XMLWriter writer, Hit hit) throws IOException {
+ if (hit instanceof DefaultErrorHit) {
+ return simpleRenderDefaultErrorHit(writer, (DefaultErrorHit) hit);
+ } else if (hit instanceof GroupingListHit) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public static boolean simpleRenderDefaultErrorHit(XMLWriter writer, ErrorHit defaultErrorHit) throws IOException {
+ writer.openTag("errordetails");
+ for (Iterator i = defaultErrorHit.errorIterator(); i.hasNext();) {
+ ErrorMessage error = (ErrorMessage) i.next();
+ renderMessageDefaultErrorHit(writer, error);
+ }
+ writer.closeTag();
+ return true;
+ }
+
+ public static void renderMessageDefaultErrorHit(XMLWriter writer, ErrorMessage error) throws IOException {
+ writer.openTag("error");
+ writer.attribute("source", error.getSource());
+ writer.attribute("error", error.getMessage());
+ writer.attribute("code", Integer.toString(error.getCode()));
+ writer.content(error.getDetailedMessage(), false);
+ if (error.getCause()!=null) {
+ writer.openTag("cause");
+ writer.content("\n", true);
+ StringWriter stackTrace=new StringWriter();
+ error.getCause().printStackTrace(new PrintWriter(stackTrace));
+ writer.content(stackTrace.toString(), true);
+ writer.closeTag();
+ }
+ writer.closeTag();
+ }
+
+ public static final class RenderingVisitor extends TraceVisitor {
+
+ private static final String tag = "p";
+ private final XMLWriter writer;
+ private long baseTime;
+
+ public RenderingVisitor(XMLWriter writer,long baseTime) {
+ this.writer=writer;
+ this.baseTime=baseTime;
+ }
+
+ @Override
+ public void entering(TraceNode node) {
+ if (node.isRoot()) return;
+ writer.openTag(tag);
+ }
+
+ @Override
+ public void leaving(TraceNode node) {
+ if (node.isRoot()) return;
+ writer.closeTag();
+ }
+
+ @Override
+ public void visit(TraceNode node) {
+ if (node.isRoot()) return;
+ if (node.payload()==null) return;
+
+ writer.openTag(tag);
+ if (node.timestamp()!=0)
+ writer.content(node.timestamp()-baseTime,false).content(" ms: ", false);
+ writer.content(node.payload().toString(),false);
+ writer.closeTag();
+ }
+
+ }
+
+ private Result getResult() {
+ Result r;
+ try {
+ r = (Result) getResponse();
+ } catch (ClassCastException e) {
+ throw new IllegalArgumentException(
+ "DefaultRenderer attempted used outside a search context, got a "
+ + getResponse().getClass().getName());
+ }
+ return r;
+ }
+
+ @Override
+ public void beginResponse(OutputStream stream) throws IOException {
+ Charset cs = Charset.forName(getRequestedEncoding(getResult().getQuery()));
+ CharsetEncoder encoder = cs.newEncoder();
+ writer = wrapWriter(new ByteWriter(stream, encoder));
+
+ header(writer, getResult());
+ if (getResult().hits().getError() != null || getResult().hits().getQuery().errors().size() > 0) {
+ error(writer, getResult());
+ }
+
+ if (getResult().getConcreteHitCount() == 0) {
+ emptyResult(writer, getResult());
+ }
+
+ if (getResult().getContext(false) != null) {
+ queryContext(writer, getResult().getContext(false), getResult().getQuery());
+ }
+
+ }
+
+ /** Returns the encoding of the query, or the encoding given by the template if none is set */
+ public final String getRequestedEncoding(Query query) {
+ String encoding = query.getModel().getEncoding();
+ if (encoding != null) return encoding;
+ return getEncoding();
+ }
+
+ @Override
+ public void beginList(DataList<?> list)
+ throws IOException {
+ if (getRecursionLevel() == 1) {
+ return;
+ }
+ HitGroup hit = (HitGroup) list;
+ boolean renderedSimple = simpleRenderHit(writer, hit);
+
+ if (renderedSimple) {
+ return;
+ }
+ renderHitGroup(writer, hit);
+ }
+
+ @Override
+ public void data(Data data) throws IOException {
+ Hit hit = (Hit) data;
+ boolean renderedSimple = simpleRenderHit(writer, hit);
+
+ if (renderedSimple) {
+ return;
+ }
+ renderSingularHit(writer, hit);
+ writer.closeTag();
+ }
+
+ @Override
+ public void endList(DataList<?> list)
+ throws IOException {
+ if (getRecursionLevel() == 1) {
+ return;
+ }
+ writer.closeTag();
+ }
+
+ @Override
+ public void endResponse() throws IOException {
+ writer.closeTag();
+ writer.close();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/JsonRenderer.java b/container-search/src/main/java/com/yahoo/search/rendering/JsonRenderer.java
new file mode 100644
index 00000000000..94fe5dd446d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/JsonRenderer.java
@@ -0,0 +1,790 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.rendering;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.LongSupplier;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import com.fasterxml.jackson.core.JsonEncoding;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerationException;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.TreeNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Preconditions;
+import com.yahoo.data.JsonProducer;
+import com.yahoo.data.access.Inspectable;
+import com.yahoo.data.access.simple.JsonRender;
+import com.yahoo.document.datatypes.FieldValue;
+import com.yahoo.document.datatypes.StringFieldValue;
+import com.yahoo.document.json.JsonWriter;
+import com.yahoo.prelude.fastsearch.FastHit;
+import com.yahoo.processing.Response;
+import com.yahoo.processing.execution.Execution.Trace;
+import com.yahoo.processing.rendering.AsynchronousSectionedRenderer;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.processing.response.Data;
+import com.yahoo.processing.response.DataList;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.grouping.result.AbstractList;
+import com.yahoo.search.grouping.result.BucketGroupId;
+import com.yahoo.search.grouping.result.Group;
+import com.yahoo.search.grouping.result.GroupId;
+import com.yahoo.search.grouping.result.RawBucketId;
+import com.yahoo.search.grouping.result.RawId;
+import com.yahoo.search.grouping.result.RootGroup;
+import com.yahoo.search.grouping.result.ValueGroupId;
+import com.yahoo.search.result.Coverage;
+import com.yahoo.search.result.DefaultErrorHit;
+import com.yahoo.search.result.ErrorHit;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.result.HitGroup;
+import com.yahoo.search.result.NanNumber;
+import com.yahoo.yolean.trace.TraceNode;
+import com.yahoo.yolean.trace.TraceVisitor;
+
+/**
+ * JSON renderer for search results.
+ *
+ * @author Steinar Knutsen
+ */
+// NOTE: The JSON format is a public API. If new elements are added be sure to update the reference doc.
+public class JsonRenderer extends AsynchronousSectionedRenderer<Result> {
+
+ private static final CompoundName DEBUG_RENDERING_KEY = new CompoundName("renderer.json.debug");
+
+ private enum RenderDecision {
+ YES, NO, DO_NOT_KNOW;
+
+ boolean booleanValue() {
+ switch (this) {
+ case YES:
+ return true;
+ case NO:
+ return false;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+ };
+
+ // if this must be optimized, simply use com.fasterxml.jackson.core.SerializableString
+ private static final String BUCKET_LIMITS = "limits";
+ private static final String BUCKET_TO = "to";
+ private static final String BUCKET_FROM = "from";
+ private static final String CHILDREN = "children";
+ private static final String CONTINUATION = "continuation";
+ private static final String COVERAGE = "coverage";
+ private static final String COVERAGE_COVERAGE = "coverage";
+ private static final String COVERAGE_DOCUMENTS = "documents";
+ private static final String COVERAGE_FULL = "full";
+ private static final String COVERAGE_NODES = "nodes";
+ private static final String COVERAGE_RESULTS = "results";
+ private static final String COVERAGE_RESULTS_FULL = "resultsFull";
+ private static final String ERRORS = "errors";
+ private static final String ERROR_CODE = "code";
+ private static final String ERROR_MESSAGE = "message";
+ private static final String ERROR_SOURCE = "source";
+ private static final String ERROR_STACK_TRACE = "stackTrace";
+ private static final String ERROR_SUMMARY = "summary";
+ private static final String FIELDS = "fields";
+ private static final String ID = "id";
+ private static final String LABEL = "label";
+ private static final String RELEVANCE = "relevance";
+ private static final String ROOT = "root";
+ private static final String SOURCE = "source";
+ private static final String TOTAL_COUNT = "totalCount";
+ private static final String TRACE = "trace";
+ private static final String TRACE_CHILDREN = "children";
+ private static final String TRACE_MESSAGE = "message";
+ private static final String TRACE_TIMESTAMP = "timestamp";
+ private static final String TIMING = "timing";
+ private static final String QUERY_TIME = "querytime";
+ private static final String SUMMARY_FETCH_TIME = "summaryfetchtime";
+ private static final String SEARCH_TIME = "searchtime";
+ private static final String TYPES = "types";
+ private static final String GROUPING_VALUE = "value";
+ private static final String VESPA_HIDDEN_FIELD_PREFIX = "$";
+
+ private final JsonFactory generatorFactory;
+
+ private JsonGenerator generator;
+ private Deque<Integer> renderedChildren;
+ private boolean debugRendering;
+ private LongSupplier timeSource;
+
+ private class TraceRenderer extends TraceVisitor {
+ private final long basetime;
+ private boolean hasFieldName = false;
+ int emittedChildNesting = 0;
+ int currentChildNesting = 0;
+ private boolean insideOpenObject = false;
+
+ TraceRenderer(long basetime) {
+ this.basetime = basetime;
+ }
+
+ @Override
+ public void entering(TraceNode node) {
+ ++currentChildNesting;
+ }
+
+ @Override
+ public void leaving(TraceNode node) {
+ conditionalEndObject();
+ if (currentChildNesting == emittedChildNesting) {
+ try {
+ generator.writeEndArray();
+ generator.writeEndObject();
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ --emittedChildNesting;
+ }
+ --currentChildNesting;
+ }
+
+ @Override
+ public void visit(TraceNode node) {
+ try {
+ doVisit(node.timestamp(), node.payload(), node.children().iterator().hasNext());
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ }
+
+ private void doVisit(final long timestamp, final Object payload, final boolean hasChildren)
+ throws IOException, JsonGenerationException {
+ boolean dirty = false;
+ if (timestamp != 0L) {
+ header();
+ generator.writeStartObject();
+ generator.writeNumberField(TRACE_TIMESTAMP, timestamp - basetime);
+ dirty = true;
+ }
+ if (payload != null) {
+ if (!dirty) {
+ header();
+ generator.writeStartObject();
+ }
+ generator.writeStringField(TRACE_MESSAGE, payload.toString());
+ dirty = true;
+ }
+ if (dirty) {
+ if (!hasChildren) {
+ generator.writeEndObject();
+ } else {
+ setInsideOpenObject(true);
+ }
+ }
+ }
+
+ private void header() {
+ fieldName();
+ for (int i = 0; i < (currentChildNesting - emittedChildNesting); ++i) {
+ startChildArray();
+ }
+ emittedChildNesting = currentChildNesting;
+ }
+
+ private void startChildArray() {
+ try {
+ conditionalStartObject();
+ generator.writeArrayFieldStart(TRACE_CHILDREN);
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ }
+
+ private void conditionalStartObject() throws IOException, JsonGenerationException {
+ if (!isInsideOpenObject()) {
+ generator.writeStartObject();
+ } else {
+ setInsideOpenObject(false);
+ }
+ }
+
+ private void conditionalEndObject() {
+ if (isInsideOpenObject()) {
+ // This triggers if we were inside a data node with payload and
+ // subnodes, but none of the subnodes contained data
+ try {
+ generator.writeEndObject();
+ setInsideOpenObject(false);
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ }
+ }
+
+ private void fieldName() {
+ if (hasFieldName) {
+ return;
+ }
+
+ try {
+ generator.writeFieldName(TRACE);
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ hasFieldName = true;
+ }
+
+ boolean isInsideOpenObject() {
+ return insideOpenObject;
+ }
+
+ void setInsideOpenObject(boolean insideOpenObject) {
+ this.insideOpenObject = insideOpenObject;
+ }
+ }
+
+ private static final class TraceRenderWrapper extends RuntimeException {
+
+ /**
+ * Should never be serialized, but this is still needed.
+ */
+ private static final long serialVersionUID = 2L;
+
+ TraceRenderWrapper(IOException wrapped) {
+ super(wrapped);
+ }
+
+ }
+
+ public JsonRenderer() {
+ generatorFactory = new JsonFactory();
+ generatorFactory.setCodec(createJsonCodec());
+ }
+
+ /**
+ * Create the codec used for rendering instances of {@link TreeNode}. This
+ * method will be invoked when creating the first renderer instance, but not
+ * for each fresh clone used by individual results.
+ *
+ * @return an object mapper for the internal JsonFactory
+ */
+ protected static ObjectMapper createJsonCodec() {
+ return new ObjectMapper();
+ }
+
+ @Override
+ public void init() {
+ super.init();
+ generator = null;
+ renderedChildren = null;
+ debugRendering = false;
+ timeSource = () -> System.currentTimeMillis();
+ }
+
+ @Override
+ public void beginResponse(OutputStream stream) throws IOException {
+ generator = generatorFactory.createGenerator(stream, JsonEncoding.UTF8);
+ renderedChildren = new ArrayDeque<>();
+ debugRendering = getDebugRendering(getResult().getQuery());
+ generator.writeStartObject();
+ renderTrace(getExecution().trace());
+ renderTiming();
+ generator.writeFieldName(ROOT);
+ }
+
+ private void renderTiming() throws IOException {
+ if (!getResult().getQuery().getPresentation().getTiming()) {
+ return;
+ }
+
+ final double milli = .001d;
+ final long now = timeSource.getAsLong();
+ final long searchTime = now - getResult().getElapsedTime().first();
+ final double searchSeconds = searchTime * milli;
+
+ generator.writeObjectFieldStart(TIMING);
+ if (getResult().getElapsedTime().firstFill() != 0L) {
+ final long queryTime = getResult().getElapsedTime().weightedSearchTime();
+ final long summaryFetchTime = getResult().getElapsedTime().weightedFillTime();
+ final double querySeconds = queryTime * milli;
+ final double summarySeconds = summaryFetchTime * milli;
+ generator.writeNumberField(QUERY_TIME, querySeconds);
+ generator.writeNumberField(SUMMARY_FETCH_TIME, summarySeconds);
+ }
+
+ generator.writeNumberField(SEARCH_TIME, searchSeconds);
+ generator.writeEndObject();
+ }
+
+ private boolean getDebugRendering(Query q) {
+ return q == null ? false : q.properties().getBoolean(DEBUG_RENDERING_KEY, false);
+ }
+
+ private void renderTrace(Trace trace) throws JsonGenerationException, IOException {
+ if (!trace.traceNode().children().iterator().hasNext()) {
+ return;
+ }
+ try {
+ long basetime = trace.traceNode().timestamp();
+ if (basetime == 0L) {
+ basetime = getResult().getElapsedTime().first();
+ }
+ trace.accept(new TraceRenderer(basetime));
+ } catch (TraceRenderWrapper e) {
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void beginList(DataList<?> list) throws IOException {
+ Preconditions.checkArgument(list instanceof HitGroup,
+ "Expected subclass of com.yahoo.search.result.HitGroup, got %s.",
+ list.getClass());
+ moreChildren();
+
+ renderHitGroupHead((HitGroup) list);
+ }
+
+ protected void moreChildren() throws IOException, JsonGenerationException {
+ if (!renderedChildren.isEmpty()) {
+ childrenArray();
+ }
+ renderedChildren.push(0);
+ }
+
+ private void childrenArray() throws IOException, JsonGenerationException {
+ if (renderedChildren.peek() == 0) {
+ generator.writeArrayFieldStart(CHILDREN);
+ }
+ renderedChildren.push(renderedChildren.pop() + 1);
+ }
+
+ private void lessChildren() throws IOException, JsonGenerationException {
+ int lastRenderedChildren = renderedChildren.pop();
+ if (lastRenderedChildren > 0) {
+ generator.writeEndArray();
+ }
+ }
+
+ private void renderHitGroupHead(HitGroup hitGroup) throws JsonGenerationException, IOException {
+ final ErrorHit errorHit = hitGroup.getErrorHit();
+
+ generator.writeStartObject();
+ renderHitContents(hitGroup);
+ if (getRecursionLevel() == 1) {
+ renderCoverage();
+ }
+ if (errorHit != null) {
+ renderErrors(errorHit.errors());
+ }
+
+ // the framework will invoke begin methods as needed from here
+ }
+
+ private void renderErrors(Set<ErrorMessage> errors) throws JsonGenerationException, IOException {
+ if (errors.isEmpty()) {
+ return;
+ }
+ generator.writeArrayFieldStart(ERRORS);
+ for (ErrorMessage e : errors) {
+ String summary = e.getMessage();
+ String source = e.getSource();
+ Throwable cause = e.getCause();
+ String message = e.getDetailedMessage();
+ generator.writeStartObject();
+ generator.writeNumberField(ERROR_CODE, e.getCode());
+ generator.writeStringField(ERROR_SUMMARY, summary);
+ if (source != null) {
+ generator.writeStringField(ERROR_SOURCE, source);
+ }
+ if (message != null) {
+ generator.writeStringField(ERROR_MESSAGE, message);
+ }
+ if (cause != null && cause.getStackTrace().length > 0) {
+ StringWriter s = new StringWriter();
+ PrintWriter p = new PrintWriter(s);
+ cause.printStackTrace(p);
+ p.close();
+ generator.writeStringField(ERROR_STACK_TRACE, s.toString());
+ }
+ generator.writeEndObject();
+ }
+ generator.writeEndArray();
+
+
+ }
+
+ private void renderCoverage() throws JsonGenerationException, IOException {
+ Coverage c = getResult().getCoverage(false);
+ if (c == null) {
+ return;
+ }
+ generator.writeObjectFieldStart(COVERAGE);
+ generator.writeNumberField(COVERAGE_COVERAGE, c.getResultPercentage());
+ generator.writeNumberField(COVERAGE_DOCUMENTS, c.getDocs());
+ generator.writeBooleanField(COVERAGE_FULL, c.getFull());
+ generator.writeNumberField(COVERAGE_NODES, c.getNodes());
+ generator.writeNumberField(COVERAGE_RESULTS, c.getResultSets());
+ generator.writeNumberField(COVERAGE_RESULTS_FULL, c.getFullResultSets());
+ generator.writeEndObject();
+ }
+
+ private void renderHit(Hit hit) throws JsonGenerationException, IOException {
+ if (!shouldRender(hit)) {
+ return;
+ }
+
+ childrenArray();
+ generator.writeStartObject();
+ renderHitContents(hit);
+ generator.writeEndObject();
+ }
+
+ private boolean shouldRender(Hit hit) {
+ if (hit instanceof DefaultErrorHit) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private boolean fieldsStart(boolean hasFieldsField) throws JsonGenerationException, IOException {
+ if (hasFieldsField) {
+ return true;
+ }
+ generator.writeObjectFieldStart(FIELDS);
+ return true;
+ }
+
+ private void fieldsEnd(boolean hasFieldsField) throws JsonGenerationException, IOException {
+ if (!hasFieldsField) {
+ return;
+ }
+ generator.writeEndObject();
+ }
+
+ private void renderHitContents(Hit hit) throws JsonGenerationException, IOException {
+ String id = hit.getDisplayId();
+ Set<String> types = hit.types();
+ String source = hit.getSource();
+
+ if (id != null) {
+ generator.writeStringField(ID, id);
+ }
+ generator.writeNumberField(RELEVANCE, hit.getRelevance().getScore());
+ if (types.size() > 0) {
+ generator.writeArrayFieldStart(TYPES);
+ for (String t : types) {
+ generator.writeString(t);
+ }
+ generator.writeEndArray();
+ }
+ if (source != null) {
+ generator.writeStringField(SOURCE, hit.getSource());
+ }
+ renderSpecialCasesForGrouping(hit);
+
+ renderAllFields(hit);
+ }
+
+ private void renderAllFields(Hit hit) throws JsonGenerationException,
+ IOException {
+ boolean hasFieldsField = false;
+
+ hasFieldsField |= renderTotalHitCount(hit, hasFieldsField);
+ hasFieldsField |= renderStandardFields(hit, hasFieldsField);
+ fieldsEnd(hasFieldsField);
+ }
+
+ private boolean renderStandardFields(Hit hit, boolean initialHasFieldsField)
+ throws JsonGenerationException, IOException {
+ boolean hasFieldsField = initialHasFieldsField;
+ for (String fieldName : hit.fieldKeys()) {
+ if (!shouldRender(fieldName, hit)) continue;
+
+ // We can't look at the size of fieldKeys() and know whether we need
+ // the fields object, as all fields may be hidden.
+ hasFieldsField |= fieldsStart(hasFieldsField);
+ renderField(fieldName, hit);
+ }
+ return hasFieldsField;
+ }
+
+ private boolean shouldRender(String fieldName, Hit hit) {
+ if (debugRendering) {
+ return true;
+ }
+ if (fieldName.startsWith(VESPA_HIDDEN_FIELD_PREFIX)) {
+ return false;
+ }
+
+ RenderDecision r = lazyRenderAwareCheck(fieldName, hit);
+ if (r != RenderDecision.DO_NOT_KNOW) {
+ return r.booleanValue();
+ }
+
+ // this will trigger field decoding, so it is important the lazy decoding magic is done first
+ Object field = hit.getField(fieldName);
+
+ if (field instanceof CharSequence && ((CharSequence) field).length() == 0) {
+ return false;
+ }
+ if (field instanceof StringFieldValue && ((StringFieldValue) field).getString().isEmpty()) {
+ // StringFieldValue cannot hold a null, so checking length directly is OK
+ return false;
+ }
+ if (field instanceof NanNumber) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private RenderDecision lazyRenderAwareCheck(String fieldName, Hit hit) {
+ if (!(hit instanceof FastHit)) return RenderDecision.DO_NOT_KNOW;
+
+ FastHit asFastHit = (FastHit) hit;
+ if (asFastHit.fieldIsNotDecoded(fieldName)) {
+ FastHit.RawField r = asFastHit.fetchFieldAsUtf8(fieldName);
+ if (r != null) {
+ byte[] utf8 = r.getUtf8();
+ if (utf8.length == 0) {
+ return RenderDecision.NO;
+ } else {
+ return RenderDecision.YES;
+ }
+ }
+ }
+ return RenderDecision.DO_NOT_KNOW;
+ }
+
+ private void renderSpecialCasesForGrouping(Hit hit)
+ throws JsonGenerationException, IOException {
+ if (hit instanceof AbstractList) {
+ renderGroupingListSyntheticFields((AbstractList) hit);
+ } else if (hit instanceof Group) {
+ renderGroupingGroupSyntheticFields(hit);
+ }
+ }
+
+ private void renderGroupingGroupSyntheticFields(Hit hit)
+ throws JsonGenerationException, IOException {
+ renderGroupMetadata(((Group) hit).getGroupId());
+ if (hit instanceof RootGroup) {
+ renderContinuations(Collections.singletonMap(
+ Continuation.THIS_PAGE, ((RootGroup) hit).continuation()));
+ }
+ }
+
+ private void renderGroupingListSyntheticFields(AbstractList a)
+ throws JsonGenerationException, IOException {
+ writeGroupingLabel(a);
+ renderContinuations(a.continuations());
+ }
+
+ private void writeGroupingLabel(AbstractList a)
+ throws JsonGenerationException, IOException {
+ generator.writeStringField(LABEL, a.getLabel());
+ }
+
+ private void renderContinuations(Map<String, Continuation> continuations)
+ throws JsonGenerationException, IOException {
+ if (continuations.isEmpty()) {
+ return;
+ }
+ generator.writeObjectFieldStart(CONTINUATION);
+ for (Map.Entry<String, Continuation> e : continuations.entrySet()) {
+ generator.writeStringField(e.getKey(), e.getValue().toString());
+ }
+ generator.writeEndObject();
+ }
+
+ private void renderGroupMetadata(GroupId id) throws JsonGenerationException,
+ IOException {
+ if (!(id instanceof ValueGroupId || id instanceof BucketGroupId)) {
+ return;
+ }
+
+ if (id instanceof ValueGroupId) {
+ final ValueGroupId<?> valueId = (ValueGroupId<?>) id;
+ generator.writeStringField(GROUPING_VALUE, getIdValue(valueId));
+ } else if (id instanceof BucketGroupId) {
+ final BucketGroupId<?> bucketId = (BucketGroupId<?>) id;
+ generator.writeObjectFieldStart(BUCKET_LIMITS);
+ generator.writeStringField(BUCKET_FROM, getBucketFrom(bucketId));
+ generator.writeStringField(BUCKET_TO, getBucketTo(bucketId));
+ generator.writeEndObject();
+ }
+ }
+
+ private static String getIdValue(ValueGroupId<?> id) {
+ return (id instanceof RawId ? Arrays.toString(((RawId) id).getValue())
+ : id.getValue()).toString();
+ }
+
+ private static String getBucketFrom(BucketGroupId<?> id) {
+ return (id instanceof RawBucketId ? Arrays.toString(((RawBucketId) id)
+ .getFrom()) : id.getFrom()).toString();
+ }
+
+ private static String getBucketTo(BucketGroupId<?> id) {
+ return (id instanceof RawBucketId ? Arrays.toString(((RawBucketId) id)
+ .getTo()) : id.getTo()).toString();
+ }
+
+ private boolean renderTotalHitCount(Hit hit, boolean hasFieldsField)
+ throws JsonGenerationException, IOException {
+ if (getRecursionLevel() == 1 && hit instanceof HitGroup) {
+ fieldsStart(hasFieldsField);
+ generator.writeNumberField(TOTAL_COUNT, getResult()
+ .getTotalHitCount());
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private void renderField(String fieldName, Hit hit) throws JsonGenerationException, IOException {
+ generator.writeFieldName(fieldName);
+ if (!tryDirectRendering(fieldName, hit)) {
+ renderFieldContents(hit.getField(fieldName));
+ }
+ }
+
+ private void renderFieldContents(Object field) throws JsonGenerationException, IOException {
+ if (field == null) {
+ generator.writeNull();
+ } else if (field instanceof Number) {
+ renderNumberField((Number) field);
+ } else if (field instanceof TreeNode) {
+ generator.writeTree((TreeNode) field);
+ } else if (field instanceof JsonProducer) {
+ generator.writeRawValue(((JsonProducer) field).toJson());
+ } else if (field instanceof Inspectable) {
+ StringBuilder intermediate = new StringBuilder();
+ JsonRender.render((Inspectable) field, intermediate, true);
+ generator.writeRawValue(intermediate.toString());
+ } else if (field instanceof StringFieldValue) {
+ // This needs special casing as JsonWriter hides empty strings now
+ generator.writeString(((StringFieldValue) field).getString());
+ } else if (field instanceof FieldValue) {
+ // the null below is the field which has already been written
+ ((FieldValue) field).serialize(null, new JsonWriter(generator));
+ } else if (field instanceof JSONArray || field instanceof JSONObject) {
+ // org.json returns null if the object would not result in
+ // syntactically correct JSON
+ String s = field.toString();
+ if (s == null) {
+ generator.writeNull();
+ } else {
+ generator.writeRawValue(s);
+ }
+ } else {
+ generator.writeString(field.toString());
+ }
+ }
+
+ private void renderNumberField(Number field) throws JsonGenerationException, IOException {
+ if (field instanceof Integer) {
+ generator.writeNumber(field.intValue());
+ } else if (field instanceof Float) {
+ generator.writeNumber(field.floatValue());
+ } else if (field instanceof Double) {
+ generator.writeNumber(field.doubleValue());
+ } else if (field instanceof Long) {
+ generator.writeNumber(field.longValue());
+ } else if (field instanceof Byte || field instanceof Short) {
+ generator.writeNumber(field.intValue());
+ } else if (field instanceof BigInteger) {
+ generator.writeNumber((BigInteger) field);
+ } else if (field instanceof BigDecimal) {
+ generator.writeNumber((BigDecimal) field);
+ } else {
+ generator.writeNumber(field.doubleValue());
+ }
+ }
+
+ /**
+ * Really a private method, but package access for testability.
+ */
+ boolean tryDirectRendering(String fieldName, Hit hit)
+ throws IOException, JsonGenerationException {
+ boolean renderedAsUtf8 = false;
+ if (hit instanceof FastHit) {
+ FastHit f = (FastHit) hit;
+ if (f.fieldIsNotDecoded(fieldName)) {
+ FastHit.RawField r = f.fetchFieldAsUtf8(fieldName);
+ if (r != null) {
+ byte[] utf8 = r.getUtf8();
+
+ generator.writeUTF8String(utf8, 0, utf8.length);
+ renderedAsUtf8 = true;
+ }
+ }
+ }
+ return renderedAsUtf8;
+ }
+
+ @Override
+ public void data(Data data) throws IOException {
+ Preconditions.checkArgument(data instanceof Hit,
+ "Expected subclass of com.yahoo.search.result.Hit, got %s.",
+ data.getClass());
+ renderHit((Hit) data);
+ }
+
+ @Override
+ public void endList(DataList<?> list) throws IOException {
+ lessChildren();
+ generator.writeEndObject();
+ }
+
+ @Override
+ public void endResponse() throws IOException {
+ generator.close();
+ }
+
+ @Override
+ public String getEncoding() {
+ return "utf-8";
+ }
+
+ @Override
+ public String getMimeType() {
+ return "application/json";
+ }
+
+ private Result getResult() {
+ Response r = getResponse();
+ Preconditions.checkArgument(r instanceof Result,
+ "JsonRenderer can only render instances of com.yahoo.search.Result, got instance of %s.",
+ r.getClass());
+ return (Result) r;
+ }
+
+ /**
+ * Only for testing. Never to be used in any other context.
+ */
+ void setGenerator(JsonGenerator generator) {
+ this.generator = generator;
+ }
+
+ /**
+ * Only for testing. Never to be used in any other context.
+ */
+ void setTimeSource(LongSupplier timeSource) {
+ this.timeSource = timeSource;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/Renderer.java b/container-search/src/main/java/com/yahoo/search/rendering/Renderer.java
new file mode 100644
index 00000000000..92e3bb15d06
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/Renderer.java
@@ -0,0 +1,96 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.rendering;
+
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import com.yahoo.io.ByteWriter;
+import com.yahoo.processing.Request;
+import com.yahoo.processing.execution.Execution;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetEncoder;
+
+/**
+ * Renders a search result to a writer synchronously - the result is completely rendered when the render method returns..
+ * The renderers are cloned just before rendering,
+ * and must therefore obey the following contract:
+ *
+ * <ol>
+ * <li>At construction time, only final members shall be initialized, and these
+ * must refer to immutable data only.</li>
+ * <li>State mutated during rendering shall be initialized in the init method.</li>
+ * </ol>
+ *
+ * @author tonytv
+ */
+abstract public class Renderer extends com.yahoo.processing.rendering.Renderer<Result> {
+
+ /**
+ * Renders synchronously and returns when rendering is complete.
+ *
+ * @return a future which is always completed to true
+ */
+ @Override
+ public final ListenableFuture<Boolean> render(OutputStream stream, Result response, Execution execution, Request request) {
+ Writer writer = null;
+ try {
+ writer = createWriter(stream,response);
+ render(writer, response);
+ }
+ catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ finally {
+ if (writer !=null)
+ try { writer.close(); } catch (IOException e2) {};
+ }
+ SettableFuture<Boolean> completed=SettableFuture.create();
+ completed.set(true);
+ return completed;
+ }
+
+ /**
+ * Renders the result to the writer.
+ */
+ protected abstract void render(Writer writer, Result result) throws IOException;
+
+ private Writer createWriter(OutputStream stream,Result result) {
+ Charset cs = Charset.forName(getCharacterEncoding(result));
+ CharsetEncoder encoder = cs.newEncoder();
+ return new ByteWriter(stream, encoder);
+ }
+
+ public String getCharacterEncoding(Result result) {
+ String encoding = result.getQuery().getModel().getEncoding();
+ return (encoding != null) ? encoding : getEncoding();
+ }
+
+ /**
+ * @return The summary class to fill the hits with if no summary class was
+ * specified in the query presentation.
+ */
+ public String getDefaultSummaryClass() {
+ return null;
+ }
+
+ /** Returns the encoding of the query, or the encoding given by the template if none is set */
+ public final String getRequestedEncoding(Query query) {
+ String encoding = query.getModel().getEncoding();
+ if (encoding != null) return encoding;
+ return getEncoding();
+ }
+
+ /**
+ * Used to create a separate instance for each result to render.
+ */
+ @Override
+ public Renderer clone() {
+ return (Renderer) super.clone();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/RendererRegistry.java b/container-search/src/main/java/com/yahoo/search/rendering/RendererRegistry.java
new file mode 100644
index 00000000000..b60c58fd90f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/RendererRegistry.java
@@ -0,0 +1,103 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.rendering;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.prelude.templates.PageTemplateSet;
+import com.yahoo.prelude.templates.SearchRendererAdaptor;
+import com.yahoo.prelude.templates.TiledTemplateSet;
+import com.yahoo.prelude.templates.UserTemplate;
+import com.yahoo.processing.rendering.Renderer;
+import com.yahoo.search.Result;
+
+import java.util.Collection;
+import java.util.Collections;
+
+/**
+ * Holds all configured and built-in renderers.
+ * This registry is always frozen.
+ *
+ * @author bratseth
+ */
+public final class RendererRegistry extends ComponentRegistry<com.yahoo.processing.rendering.Renderer<Result>> {
+
+ public static final ComponentId xmlRendererId = ComponentId.fromString("DefaultRenderer");
+ public static final ComponentId jsonRendererId = ComponentId.fromString("JsonRenderer");
+ public static final ComponentId defaultRendererId = jsonRendererId;
+
+ /** Creates a registry containing the built-in renderers only */
+ public RendererRegistry() {
+ this(Collections.emptyList());
+ }
+
+ /** Creates a registry of the given renderers plus the built-in ones */
+ public RendererRegistry(Collection<Renderer> renderers) {
+ // add json renderer
+ Renderer jsonRenderer = new JsonRenderer();
+ jsonRenderer.initId(RendererRegistry.jsonRendererId);
+ register(jsonRenderer.getId(), jsonRenderer);
+
+ // Add xml renderer
+ Renderer xmlRenderer = new DefaultRenderer();
+ xmlRenderer.initId(xmlRendererId);
+ register(xmlRenderer.getId(), xmlRenderer);
+
+ // add application renderers
+ for (Renderer renderer : renderers)
+ register(renderer.getId(), renderer);
+
+ // add legacy "templates" converted to renderers
+ addTemplateSet(new TiledTemplateSet());
+ addTemplateSet(new PageTemplateSet());
+
+ freeze();
+ }
+
+ @SuppressWarnings({"deprecation", "unchecked"})
+ private void addTemplateSet(UserTemplate<?> templateSet) {
+ Renderer renderer = new SearchRendererAdaptor(templateSet);
+ ComponentId rendererId = new ComponentId(templateSet.getName());
+ renderer.initId(rendererId);
+ register(rendererId, renderer);
+ }
+
+ /**
+ * Returns the default JSON renderer
+ *
+ * @return the default built-in result renderer
+ */
+ public com.yahoo.processing.rendering.Renderer<Result> getDefaultRenderer() {
+ return getComponent(jsonRendererId);
+ }
+
+ /**
+ * Returns the requested renderer.
+ *
+ * @param format the id or format alias of the renderer to return. If null is passed the default renderer
+ * is returned
+ * @throws IllegalArgumentException if the renderer cannot be resolved
+ */
+ public com.yahoo.processing.rendering.Renderer<Result> getRenderer(ComponentSpecification format) {
+ if (format == null || format.stringValue().equals("default")) return getDefaultRenderer();
+ if (format.stringValue().equals("json")) return getComponent(jsonRendererId);
+ if (format.stringValue().equals("xml")) return getComponent(xmlRendererId);
+
+ com.yahoo.processing.rendering.Renderer<Result> renderer = getComponent(format);
+ if (renderer == null)
+ throw new IllegalArgumentException("No renderer with id or alias '" + format + "'. " +
+ "Available renderers are: [" + rendererNames() + "].");
+ return renderer;
+ }
+
+ private String rendererNames() {
+ StringBuilder r = new StringBuilder();
+ for (Renderer<Result> c : allComponents()) {
+ if (r.length() > 0)
+ r.append(", ");
+ r.append(c.getId().stringValue());
+ }
+ return r.toString();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/SectionedRenderer.java b/container-search/src/main/java/com/yahoo/search/rendering/SectionedRenderer.java
new file mode 100644
index 00000000000..98978b76277
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/SectionedRenderer.java
@@ -0,0 +1,220 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.rendering;
+
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.result.Group;
+import com.yahoo.search.grouping.result.GroupList;
+import com.yahoo.search.grouping.result.HitList;
+import com.yahoo.search.query.context.QueryContext;
+import com.yahoo.search.result.ErrorHit;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.result.HitGroup;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+
+/**
+ * Renders each part of a result to a writer.
+ * The renderers are cloned just before rendering,
+ * and must therefore obey the following contract:
+ * <ol>
+ * <li>At construction time, only final members shall be initialized,
+ * and these must refer to immutable data only.
+ * <li>State mutated during rendering shall be initialized in the init method.
+ * </ol>
+ *
+ * @author tonytv
+ */
+abstract public class SectionedRenderer<WRITER> extends Renderer {
+ /**
+ * Wraps the Writer instance.
+ * The result is given as a parameter to all the callback methods.
+ * Must be overridden if the generic parameter WRITER != java.io.Writer.
+ */
+ @SuppressWarnings("unchecked")
+ public WRITER wrapWriter(Writer writer) {
+ return (WRITER)writer;
+ }
+
+ /**
+ * Called at the start of rendering.
+ */
+ abstract public void beginResult(WRITER writer, Result result) throws IOException;
+
+ /**
+ * Called at the end of rendering.
+ */
+ abstract public void endResult(WRITER writer, Result result) throws IOException;
+
+ /**
+ * Called if there are errors in the result.
+ */
+ abstract public void error(WRITER writer, Collection<ErrorMessage> errorMessages) throws IOException;
+
+ /**
+ * Called if there are no hits in the result.
+ */
+ abstract public void emptyResult(WRITER writer, Result result) throws IOException;
+
+ /**
+ * Called if there is a non-null query context for the query of the result.
+ */
+ abstract public void queryContext(WRITER writer, QueryContext queryContext) throws IOException;
+
+ /**
+ * Called when a HitGroup is encountered. After all its children have been provided
+ * to methods of this class, endHitGroup is called.
+ */
+ abstract public void beginHitGroup(WRITER writer, HitGroup hitGroup) throws IOException;
+
+ /**
+ * Called after all the children of the HitGroup have been provided to methods of this class.
+ * See beginHitGroup.
+ */
+ abstract public void endHitGroup(WRITER writer, HitGroup hitGroup) throws IOException;
+
+ /**
+ * Called when a Hit is encountered.
+ */
+ abstract public void hit(WRITER writer, Hit hit) throws IOException;
+
+ /**
+ * Called when an errorHit is encountered.
+ * Forwards to hit() per default.
+ */
+ public void errorHit(WRITER writer, ErrorHit errorHit) throws IOException {
+ hit(writer, (Hit)errorHit);
+ }
+
+ /* Begin Grouping */
+
+ /**
+ * Same as beginHitGroup, but for Group(grouping api).
+ * Forwards to beginHitGroup() per default.
+ */
+ public void beginGroup(WRITER writer, Group group) throws IOException {
+ beginHitGroup(writer, group);
+ }
+
+ /**
+ * Same as endHitGroup, but for Group(grouping api).
+ * Forwards to endHitGroup() per default.
+ */
+ public void endGroup(WRITER writer, Group group) throws IOException {
+ endHitGroup(writer, group);
+ }
+
+ /**
+ * Same as beginHitGroup, but for GroupList(grouping api).
+ * Forwards to beginHitGroup() per default.
+ */
+ public void beginGroupList(WRITER writer, GroupList groupList) throws IOException {
+ beginHitGroup(writer, groupList);
+ }
+
+ /**
+ * Same as endHitGroup, but for GroupList(grouping api).
+ * Forwards to endHitGroup() per default.
+ */
+ public void endGroupList(WRITER writer, GroupList groupList) throws IOException {
+ endHitGroup(writer, groupList);
+ }
+
+ /**
+ * Same as beginHitGroup, but for HitList(grouping api).
+ * Forwards to beginHitGroup() per default.
+ */
+ public void beginHitList(WRITER writer, HitList hitList) throws IOException {
+ beginHitGroup(writer, hitList);
+ }
+
+ /**
+ * Same as endHitGroup, but for HitList(grouping api).
+ * Forwards to endHitGroup() per default.
+ */
+ public void endHitList(WRITER writer, HitList hitList) throws IOException {
+ endHitGroup(writer, hitList);
+ }
+ /* End Grouping */
+
+ /**
+ * Picks apart the result and feeds it to the other methods.
+ */
+ @Override
+ public final void render(Writer writer, Result result) throws IOException {
+ WRITER wrappedWriter = wrapWriter(writer);
+
+ beginResult(wrappedWriter, result);
+ renderResultContent(wrappedWriter, result);
+ endResult(wrappedWriter, result);
+ }
+
+ private void renderResultContent(WRITER writer, Result result) throws IOException {
+ if (result.hits().getError() != null || result.hits().getQuery().errors().size() > 0) {
+ error(writer, asUnmodifiableSearchErrorList(result.hits().getQuery().errors(), result.hits().getError()));
+ }
+
+ if (result.getConcreteHitCount() == 0) {
+ emptyResult(writer, result);
+ }
+
+ if (result.getContext(false) != null) {
+ queryContext(writer, result.getContext(false));
+ }
+
+ renderHitGroup(writer, result.hits());
+ }
+
+ private Collection<ErrorMessage> asUnmodifiableSearchErrorList(List<com.yahoo.processing.request.ErrorMessage> queryErrors,ErrorMessage resultError) {
+ if (queryErrors.size() == 0)
+ return Collections.singletonList(resultError);
+ List<ErrorMessage> searchErrors = new ArrayList<>(queryErrors.size() + (resultError != null ? 1 :0) );
+ for (int i=0; i<queryErrors.size(); i++)
+ searchErrors.add(ErrorMessage.from(queryErrors.get(i)));
+ if (resultError != null)
+ searchErrors.add(resultError);
+ return Collections.unmodifiableCollection(searchErrors);
+ }
+
+ private void renderHitGroup(WRITER writer, HitGroup hitGroup) throws IOException {
+ if (hitGroup instanceof GroupList) {
+ beginGroupList(writer, (GroupList) hitGroup);
+ renderHitGroupContent(writer, hitGroup);
+ endGroupList(writer, (GroupList) hitGroup);
+ } else if (hitGroup instanceof HitList) {
+ beginHitList(writer, (HitList) hitGroup);
+ renderHitGroupContent(writer, hitGroup);
+ endHitList(writer, (HitList) hitGroup);
+ } else if (hitGroup instanceof Group) {
+ beginGroup(writer, (Group) hitGroup);
+ renderHitGroupContent(writer, hitGroup);
+ endGroup(writer, (Group) hitGroup);
+ } else {
+ beginHitGroup(writer, hitGroup);
+ renderHitGroupContent(writer, hitGroup);
+ endHitGroup(writer, hitGroup);
+ }
+ }
+
+ private void renderHitGroupContent(WRITER writer, HitGroup hitGroup) throws IOException {
+ for (Hit hit : hitGroup.asList()) {
+ renderHit(writer, hit);
+ }
+ }
+
+ private void renderHit(WRITER writer, Hit hit) throws IOException {
+ if (hit instanceof HitGroup) {
+ renderHitGroup(writer, (HitGroup) hit);
+ } else if (hit instanceof ErrorHit) {
+ errorHit(writer, (ErrorHit) hit);
+ } else {
+ hit(writer, hit);
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/SyncDefaultRenderer.java b/container-search/src/main/java/com/yahoo/search/rendering/SyncDefaultRenderer.java
new file mode 100644
index 00000000000..d3039925013
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/SyncDefaultRenderer.java
@@ -0,0 +1,471 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.rendering;
+
+import com.yahoo.concurrent.CopyOnWriteHashMap;
+import com.yahoo.io.ByteWriter;
+import com.yahoo.log.LogLevel;
+import com.yahoo.net.URI;
+import com.yahoo.prelude.fastsearch.FastHit;
+import com.yahoo.prelude.fastsearch.GroupingListHit;
+import com.yahoo.prelude.templates.Context;
+import com.yahoo.prelude.templates.DefaultTemplateSet;
+import com.yahoo.prelude.templates.MapContext;
+import com.yahoo.prelude.templates.UserTemplate;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.grouping.result.HitRenderer;
+import com.yahoo.search.query.context.QueryContext;
+import com.yahoo.search.result.*;
+import com.yahoo.text.Utf8String;
+import com.yahoo.text.XMLWriter;
+import com.yahoo.yolean.trace.TraceNode;
+import com.yahoo.yolean.trace.TraceVisitor;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.logging.Logger;
+
+/**
+ * @author tonytv
+ */
+@SuppressWarnings({ "rawtypes", "deprecation" })
+public final class SyncDefaultRenderer extends Renderer {
+
+ private static final Logger log = Logger.getLogger(SyncDefaultRenderer.class.getName());
+
+ public static final String DEFAULT_MIMETYPE = "text/xml";
+ public static final String DEFAULT_ENCODING = "utf-8";
+
+
+ private static final Utf8String RESULT = new Utf8String("result");
+ private static final Utf8String GROUP = new Utf8String("group");
+ private static final Utf8String ID = new Utf8String("id");
+ private static final Utf8String FIELD = new Utf8String("field");
+ private static final Utf8String HIT = new Utf8String("hit");
+ private static final Utf8String ERROR = new Utf8String("error");
+ private static final Utf8String TOTAL_HIT_COUNT = new Utf8String("total-hit-count");
+ private static final Utf8String QUERY_TIME = new Utf8String("querytime");
+ private static final Utf8String SUMMARY_FETCH_TIME = new Utf8String("summaryfetchtime");
+ private static final Utf8String SEARCH_TIME = new Utf8String("searchtime");
+ private static final Utf8String NAME = new Utf8String("name");
+ private static final Utf8String CODE = new Utf8String("code");
+ private static final Utf8String COVERAGE_DOCS = new Utf8String("coverage-docs");
+ private static final Utf8String COVERAGE_NODES = new Utf8String("coverage-nodes");
+ private static final Utf8String COVERAGE_FULL = new Utf8String("coverage-full");
+ private static final Utf8String COVERAGE = new Utf8String("coverage");
+ private static final Utf8String RESULTS_FULL = new Utf8String("results-full");
+ private static final Utf8String RESULTS = new Utf8String("results");
+ private static final Utf8String TYPE = new Utf8String("type");
+ private static final Utf8String RELEVANCY = new Utf8String("relevancy");
+ private static final Utf8String SOURCE = new Utf8String("source");
+
+
+ //Per instance members, must be created at rendering time, not construction time due to cloning.
+ private Context context;
+
+ private final DefaultTemplateSet defaultTemplate = new DefaultTemplateSet();
+
+ private final CopyOnWriteHashMap<String, Utf8String> fieldNameMap = new CopyOnWriteHashMap<>();
+
+ @Override
+ public void init() {
+ super.init();
+ context = new MapContext();
+ }
+
+ @Override
+ public String getEncoding() {
+ return DEFAULT_ENCODING;
+ }
+
+ @Override
+ public String getMimeType() {
+ return DEFAULT_MIMETYPE;
+ }
+
+ @Override
+ public String getDefaultSummaryClass() {
+ return null;
+ }
+
+ private XMLWriter wrapWriter(Writer writer) {
+ return XMLWriter.from(writer, 10, -1);
+ }
+
+ /**
+ * Renders this result
+ */
+ public void render(Writer writer, Result result) throws IOException {
+ XMLWriter xmlWriter = wrapWriter(writer);
+
+ context.put("context", context);
+ context.put("result", result);
+ context.setBoldOpenTag(defaultTemplate.getBoldOpenTag());
+ context.setBoldCloseTag(defaultTemplate.getBoldCloseTag());
+ context.setSeparatorTag(defaultTemplate.getSeparatorTag());
+
+ try {
+ header(xmlWriter, result);
+ } catch (Exception e) {
+ handleException(e);
+ }
+
+ if (result.hits().getError() != null || result.hits().getQuery().errors().size() > 0) {
+ error(xmlWriter, result);
+ }
+
+ if (result.getConcreteHitCount() == 0) {
+ emptyResult(xmlWriter, result);
+ }
+
+ if (result.getContext(false) != null) {
+ queryContext(xmlWriter, result.getContext(false), result.getQuery());
+ }
+
+ renderHitGroup(xmlWriter, result.hits(), result.hits().getQuery().getOffset() + 1);
+
+ endResult(xmlWriter, result);
+ }
+
+ private void header(XMLWriter writer, Result result) throws IOException {
+ // TODO: move setting this to Result
+ context.setUtf8Output("utf-8".equalsIgnoreCase(getRequestedEncoding(result.getQuery())));
+ writer.xmlHeader(getRequestedEncoding(result.getQuery()));
+ writer.openTag(RESULT).attribute(TOTAL_HIT_COUNT,String.valueOf(result.getTotalHitCount()));
+ if (result.getQuery().getPresentation().getReportCoverage()) {
+ renderCoverageAttributes(result.getCoverage(false), writer);
+ }
+ renderTime(writer, result);
+ writer.closeStartTag();
+ }
+
+ private void renderTime(XMLWriter writer, Result result) {
+ if (!result.getQuery().getPresentation().getTiming()) {
+ return;
+ }
+
+ final String threeDecimals = "%.3f";
+ final double milli = .001d;
+ final long now = System.currentTimeMillis();
+ final long searchTime = now - result.getQuery().getStartTime();
+ final double searchSeconds = ((double) searchTime) * milli;
+
+ if (result.getElapsedTime().firstFill() != 0L) {
+ final long queryTime = result.getElapsedTime().firstFill() - result.getQuery().getStartTime();
+ final long summaryFetchTime = now - result.getElapsedTime().firstFill();
+ final double querySeconds = ((double) queryTime) * milli;
+ final double summarySeconds = ((double) summaryFetchTime) * milli;
+ writer.attribute(QUERY_TIME, String.format(threeDecimals, querySeconds));
+ writer.attribute(SUMMARY_FETCH_TIME, String.format(threeDecimals, summarySeconds));
+ }
+ writer.attribute(SEARCH_TIME, String.format(threeDecimals, searchSeconds));
+ }
+
+ protected static void renderCoverageAttributes(Coverage coverage, XMLWriter writer) throws IOException {
+ if (coverage == null) return;
+ writer.attribute(COVERAGE_DOCS,coverage.getDocs());
+ writer.attribute(COVERAGE_NODES,coverage.getNodes());
+ writer.attribute(COVERAGE_FULL,coverage.getFull());
+ writer.attribute(COVERAGE,coverage.getResultPercentage());
+ writer.attribute(RESULTS_FULL,coverage.getFullResultSets());
+ writer.attribute(RESULTS,coverage.getResultSets());
+ }
+
+ public void endResult(XMLWriter writer, Result result) throws IOException {
+ try {
+ writer.closeTag();
+ } catch (Exception e) {
+ handleException(e);
+ }
+ }
+
+ public void error(XMLWriter writer, Result result) throws IOException {
+ try {
+ ErrorMessage error = result.hits().getError();
+ writer.openTag(ERROR).attribute(CODE,error.getCode()).content(error.getMessage(),false).closeTag();
+ } catch (Exception e) {
+ handleException(e);
+ }
+ }
+
+
+ protected void emptyResult(XMLWriter writer, Result result) throws IOException {}
+
+ public void queryContext(XMLWriter writer, QueryContext queryContext, Query owner) throws IOException {
+ try {
+ if (owner.getTraceLevel()!=0) {
+ XMLWriter xmlWriter=XMLWriter.from(writer);
+ xmlWriter.openTag("meta").attribute("type", QueryContext.ID);
+ TraceNode traceRoot = owner.getModel().getExecution().trace().traceNode().root();
+ traceRoot.accept(new RenderingVisitor(xmlWriter, owner.getStartTime()));
+ xmlWriter.closeTag();
+ }
+ } catch (Exception e) {
+ handleException(e);
+ }
+ }
+
+ private void renderHitGroup(XMLWriter writer, HitGroup hitGroup, int hitnumber)
+ throws IOException {
+ for (Hit hit : hitGroup.asList()) {
+ renderHit(writer, hit, hitnumber);
+ if (!hit.isAuxiliary())
+ hitnumber++;
+ }
+ }
+
+
+ /**
+ * Renders this hit as xml. The default implementation will call the simpleRender()
+ * hook. If it returns true, nothing more is done, otherwise the
+ * given template set will be used for rendering.
+ *
+ *
+ * @param writer the XMLWriter to append this hit to
+ * @throws java.io.IOException if rendering fails
+ */
+ public void renderHit(XMLWriter writer, Hit hit, int hitno) throws IOException {
+ renderRegularHit(writer, hit, hitno);
+ }
+
+ private void renderRegularHit(XMLWriter writer, Hit hit, int hitno) throws IOException {
+ boolean renderedSimple = simpleRenderHit(writer, hit);
+
+ if (renderedSimple) {
+ return;
+ }
+
+ try {
+ if (hit instanceof HitGroup) {
+ renderHitGroup(writer, (HitGroup) hit);
+ } else {
+ renderSingularHit(writer, hit);
+ }
+ } catch (Exception e) {
+ handleException(e);
+ }
+
+ if (hit instanceof HitGroup)
+ renderHitGroup(writer, (HitGroup) hit, hitno);
+
+ try {
+ writer.closeTag();
+ } catch (Exception e) {
+ handleException(e);
+ }
+ }
+
+ private void renderSingularHit(XMLWriter writer, Hit hit) throws IOException {
+ writer.openTag(HIT);
+ renderHitAttributes(writer, hit);
+ writer.closeStartTag();
+ renderHitFields(writer, hit);
+ }
+
+ private void renderHitFields(XMLWriter writer, Hit hit) throws IOException {
+ renderSyntheticRelevanceField(writer, hit);
+ for (Iterator<Map.Entry<String, Object>> it = hit.fieldIterator(); it.hasNext(); ) {
+ renderField(writer, hit, it);
+ }
+ }
+
+ private void renderField(XMLWriter writer, Hit hit, Iterator<Map.Entry<String, Object>> it) throws IOException {
+ Map.Entry<String, Object> entry = it.next();
+ boolean isProbablyNotDecoded = false;
+ if (hit instanceof FastHit) {
+ FastHit f = (FastHit) hit;
+ isProbablyNotDecoded = f.fieldIsNotDecoded(entry.getKey());
+ }
+ renderGenericFieldPossiblyNotDecoded(writer, hit, entry, isProbablyNotDecoded);
+ }
+
+ private void renderGenericFieldPossiblyNotDecoded(XMLWriter writer, Hit hit, Map.Entry<String, Object> entry, boolean probablyNotDecoded) throws IOException {
+ String fieldName = entry.getKey();
+
+ if (!shouldRenderField(hit, fieldName)) return;
+ if (fieldName.startsWith("$")) return; // Don't render fields that start with $ // TODO: Move to should render
+
+ writeOpenFieldElement(writer, fieldName);
+ renderFieldContentPossiblyNotDecoded(writer, hit, probablyNotDecoded, fieldName);
+ writeCloseFieldElement(writer);
+ }
+
+ private void renderFieldContentPossiblyNotDecoded(XMLWriter writer, Hit hit, boolean probablyNotDecoded, String fieldName) throws IOException {
+ boolean dumpedRaw = false;
+ if (probablyNotDecoded && (hit instanceof FastHit)) {
+ writer.closeStartTag();
+ if ((writer.getWriter() instanceof ByteWriter) && context.isUtf8Output()) {
+ dumpedRaw = UserTemplate.dumpBytes((ByteWriter) writer.getWriter(), (FastHit) hit, fieldName);
+ }
+ if (dumpedRaw) {
+ writer.content("", false); // let the xml writer note that this tag had content
+ }
+ }
+ if (!dumpedRaw) {
+ String xmlval = hit.getFieldXML(fieldName);
+ if (xmlval == null) {
+ xmlval = "(null)";
+ }
+ writer.escapedContent(xmlval, false);
+ }
+ }
+
+ private void renderSyntheticRelevanceField(XMLWriter writer, Hit hit) throws IOException {
+ final String relevancyFieldName = "relevancy";
+ final Relevance relevance = hit.getRelevance();
+
+ if (shouldRenderField(hit, relevancyFieldName) && relevance != null) {
+ renderSimpleField(writer, relevancyFieldName, relevance);
+ }
+ }
+
+ private void renderSimpleField(XMLWriter writer, String relevancyFieldName, Relevance relevance) throws IOException {
+ writeOpenFieldElement(writer, relevancyFieldName);
+ writer.content(relevance.toString(), false);
+ writeCloseFieldElement(writer);
+ }
+
+ private void writeCloseFieldElement(XMLWriter writer) throws IOException {
+ writer.closeTag();
+ }
+
+ private void writeOpenFieldElement(XMLWriter writer, String relevancyFieldName) throws IOException {
+ Utf8String utf8 = fieldNameMap.get(relevancyFieldName);
+ if (utf8 == null) {
+ utf8 = new Utf8String(relevancyFieldName);
+ fieldNameMap.put(relevancyFieldName, utf8);
+ }
+ writer.openTag(FIELD).attribute(NAME, utf8);
+ writer.closeStartTag();
+ }
+
+ private boolean shouldRenderField(Hit hit, String relevancyFieldName) {
+ // skip depending on hit type
+ return true;
+ }
+
+ private void renderHitAttributes(XMLWriter writer, Hit hit) throws IOException {
+ writer.attribute(TYPE, hit.getTypeString());
+ if (hit.getRelevance() != null) {
+ writer.attribute(RELEVANCY, hit.getRelevance().toString());
+}
+ writer.attribute(SOURCE, hit.getSource());
+ }
+
+ private void renderHitGroup(XMLWriter writer, HitGroup hit) throws IOException {
+ if (HitRenderer.renderHeader((HitGroup) hit, writer)) {
+ // empty
+ } else if (((HitGroup) hit).types().contains("grouphit")) {
+ // TODO Keep this?
+ renderHitGroupOfTypeGroupHit(writer, hit);
+ } else {
+ renderGroup(writer, hit);
+ }
+ }
+
+ private void renderGroup(XMLWriter writer, HitGroup hit) throws IOException {
+ writer.openTag(GROUP);
+ renderHitAttributes(writer, (HitGroup) hit);
+ writer.closeStartTag();
+ }
+
+ private void renderHitGroupOfTypeGroupHit(XMLWriter writer, HitGroup hit) throws IOException {
+ writer.openTag(HIT);
+ renderHitAttributes(writer, (HitGroup) hit);
+ renderId(writer, hit);
+ writer.closeStartTag();
+ }
+
+ private void renderId(XMLWriter writer, HitGroup hit) throws IOException {
+ URI uri = hit.getId();
+ if (uri != null) {
+ writer.openTag(ID).content(uri.stringValue(),false).closeTag();
+ }
+ }
+
+ private boolean simpleRenderHit(XMLWriter writer, Hit hit) throws IOException {
+ if (hit instanceof DefaultErrorHit) {
+ return simpleRenderDefaultErrorHit(writer, (DefaultErrorHit) hit);
+ } else if (hit instanceof GroupingListHit) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public static boolean simpleRenderDefaultErrorHit(XMLWriter writer, ErrorHit defaultErrorHit) throws IOException {
+ writer.openTag("errordetails");
+ for (Iterator i = defaultErrorHit.errorIterator(); i.hasNext();) {
+ ErrorMessage error = (ErrorMessage) i.next();
+ renderMessageDefaultErrorHit(writer, error);
+ }
+ writer.closeTag();
+ return true;
+ }
+
+ public static void renderMessageDefaultErrorHit(XMLWriter writer, ErrorMessage error) throws IOException {
+ writer.openTag("error");
+ writer.attribute("source", error.getSource());
+ writer.attribute("error", error.getMessage());
+ writer.attribute("code", Integer.toString(error.getCode()));
+ writer.content(error.getDetailedMessage(), false);
+ if (error.getCause()!=null) {
+ writer.openTag("cause");
+ writer.content("\n", true);
+ StringWriter stackTrace=new StringWriter();
+ error.getCause().printStackTrace(new PrintWriter(stackTrace));
+ writer.content(stackTrace.toString(), true);
+ writer.closeTag();
+ }
+ writer.closeTag();
+ }
+
+ private void handleException(Exception e) throws IOException {
+ if (e instanceof IOException) {
+ throw (IOException) e;
+ } else {
+ log.log(LogLevel.WARNING, "Exception thrown when rendering the result:", e);
+ }
+ }
+
+ public static final class RenderingVisitor extends TraceVisitor {
+
+ private static final String tag = "p";
+ private final XMLWriter writer;
+ private long baseTime;
+
+ public RenderingVisitor(XMLWriter writer,long baseTime) {
+ this.writer=writer;
+ this.baseTime=baseTime;
+ }
+
+ @Override
+ public void entering(TraceNode node) {
+ if (node.isRoot()) return;
+ writer.openTag(tag);
+ }
+
+ @Override
+ public void leaving(TraceNode node) {
+ if (node.isRoot()) return;
+ writer.closeTag();
+ }
+
+ @Override
+ public void visit(TraceNode node) {
+ if (node.isRoot()) return;
+ if (node.payload()==null) return;
+
+ writer.openTag(tag);
+ if (node.timestamp()!=0)
+ writer.content(node.timestamp()-baseTime,false).content(" ms: ", false);
+ writer.content(node.payload().toString(),false);
+ writer.closeTag();
+ }
+
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/rendering/package-info.java b/container-search/src/main/java/com/yahoo/search/rendering/package-info.java
new file mode 100644
index 00000000000..7411055e015
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/rendering/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.rendering;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/result/ChainableComparator.java b/container-search/src/main/java/com/yahoo/search/result/ChainableComparator.java
new file mode 100644
index 00000000000..0750618de67
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/ChainableComparator.java
@@ -0,0 +1,34 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Comparator;
+
+/**
+ * Superclass of hit comparators which delegates comparisons of hits which are
+ * equal according to this comparator, to a secondary comparator.
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public abstract class ChainableComparator implements Comparator<Hit> {
+
+ private final Comparator<Hit> secondaryComparator;
+
+ /** Creates this comparator, given a secondary comparator, or null if there is no secondary */
+ public ChainableComparator(Comparator<Hit> secondaryComparator) {
+ this.secondaryComparator=secondaryComparator;
+ }
+
+ /** Returns the comparator to use to compare hits which are equal according to this, or null if none */
+ public Comparator<Hit> getSecondaryComparator() { return secondaryComparator; }
+
+ /**
+ * Returns the comparison form the secondary comparison, or 0 if the secondary is null.
+ * When overriding this in the subclass, always <code>return super.compare(first,second)</code>
+ * at the end of the subclass' implementation.
+ */
+ public int compare(Hit first,Hit second) {
+ if (secondaryComparator==null) return 0;
+ return secondaryComparator.compare(first,second);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/Coverage.java b/container-search/src/main/java/com/yahoo/search/result/Coverage.java
new file mode 100644
index 00000000000..7d1e737bfb8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/Coverage.java
@@ -0,0 +1,24 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+/**
+ * The coverage report for a result set.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ * @author balder
+ */
+public class Coverage extends com.yahoo.container.handler.Coverage {
+
+ public Coverage(long docs, long active) {
+ super(docs, active, 0, 1);
+ }
+
+ public Coverage(long docs, int nodes, boolean full) {
+ this(docs, nodes, full, 1);
+ }
+
+ public Coverage(long docs, int nodes, boolean full, int resultSets) {
+ super(docs, nodes, full, resultSets);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/DeepHitIterator.java b/container-search/src/main/java/com/yahoo/search/result/DeepHitIterator.java
new file mode 100644
index 00000000000..a62a9c66e79
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/DeepHitIterator.java
@@ -0,0 +1,85 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.*;
+
+/**
+ * An iterator for the forest of hits in a result.
+ *
+ * @author havardpe
+ */
+public class DeepHitIterator implements Iterator<Hit> {
+
+ private final boolean ordered;
+ private List<Iterator<Hit>> stack;
+ private boolean canRemove = false;
+ private Iterator<Hit> it = null;
+ private Hit next = null;
+
+
+ /**
+ * Create a deep hit iterator based on the given hit iterator.
+ *
+ * @param it The hits iterator to traverse.
+ * @param ordered Whether or not the hits should be ordered.
+ */
+ public DeepHitIterator(Iterator<Hit> it, boolean ordered) {
+ this.ordered = ordered;
+ this.it = it;
+ }
+
+ @Override
+ public boolean hasNext() {
+ canRemove = false;
+ return getNext();
+ }
+
+ @Override
+ public Hit next() throws NoSuchElementException {
+ if (next == null && !getNext()) {
+ throw new NoSuchElementException();
+ }
+ Hit ret = next;
+ next = null;
+ canRemove = true;
+ return ret;
+ }
+
+ @Override
+ public void remove() throws UnsupportedOperationException, IllegalStateException {
+ if (!canRemove) {
+ throw new IllegalStateException("Can not remove() an element after calling hasNext().");
+ }
+ it.remove();
+ }
+
+ private boolean getNext() {
+ if (next != null) {
+ return true;
+ }
+
+ if (stack == null) {
+ stack = new ArrayList<>();
+ }
+ while (true) {
+ if (it.hasNext()) {
+ Hit hit = it.next();
+ if (hit instanceof HitGroup) {
+ stack.add(it);
+ if (ordered) {
+ it = ((HitGroup)hit).iterator();
+ } else {
+ it = ((HitGroup)hit).unorderedIterator();
+ }
+ } else {
+ next = hit;
+ return true;
+ }
+ } else if (!stack.isEmpty()) {
+ it = stack.remove(stack.size()-1);
+ } else {
+ return false;
+ }
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/DefaultErrorHit.java b/container-search/src/main/java/com/yahoo/search/result/DefaultErrorHit.java
new file mode 100644
index 00000000000..79b8d55bb07
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/DefaultErrorHit.java
@@ -0,0 +1,135 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.collections.ArraySet;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A hit which holds information on error conditions in a result.
+ * En error hit maintains a main error - the main error of the result.
+ *
+ * @author bratseth
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class DefaultErrorHit extends Hit implements ErrorHit, Cloneable {
+
+ /**
+ * A list of unique error messages, where the first is considered the "main"
+ * error. It should always contain at least one error.
+ */
+ private List<ErrorMessage> errors = new ArrayList<>();
+
+ /**
+ * Creates an error hit with a main error
+ *
+ * @param source the name of the source or backend of this hit
+ * @param error an initial main error to add to this hit, cannot be null
+ */
+ public DefaultErrorHit(String source, ErrorMessage error) {
+ super("error:" + source, new Relevance(Double.POSITIVE_INFINITY), source);
+ addError(error);
+ }
+
+ public void setSource(String source) {
+ super.setSource(source);
+ for (Iterator<ErrorMessage> i = errorIterator(); i.hasNext();) {
+ ErrorMessage error = i.next();
+
+ if (error.getSource() == null) {
+ error.setSource(source);
+ }
+ }
+ }
+
+ /**
+ * Returns the main error of this result, never null.
+ *
+ * @deprecated since 5.18, use {@link #errors()}
+ */
+ @Override
+ public ErrorMessage getMainError() {
+ return errors.get(0);
+ }
+
+ /**
+ * Insert the new "main" error at head of list, remove from the list if it
+ * already exists elsewhere.
+ */
+ private void removeAndAddAtHead(ErrorMessage mainError) {
+ errors.remove(mainError); // avoid error duplication
+ errors.add(0, mainError);
+ }
+
+ /**
+ * This is basically a way of making a list simulate a set.
+ */
+ private void removeAndAdd(ErrorMessage error) {
+ errors.remove(error);
+ errors.add(error);
+ }
+
+ /**
+ * Adds an error to this. This may change the main error
+ * and/or the list of detailed errors
+ */
+ public void addError(ErrorMessage error) {
+ if (error.getSource() == null) {
+ error.setSource(getSource());
+ }
+ removeAndAdd(error);
+ }
+
+
+ /** Add all errors from another error hit to this */
+ public void addErrors(ErrorHit errorHit) {
+ for (Iterator<? extends ErrorMessage> i = errorHit.errorIterator(); i.hasNext();) {
+ addError(i.next());
+ }
+ }
+
+ /**
+ * Returns all the detail errors of this error hit, not including the main error.
+ * The iterator is modifiable.
+ */
+ public Iterator<ErrorMessage> errorIterator() {
+ return errors.iterator();
+ }
+
+ /** Returns a read-only set containing all the error of this */
+ public Set<ErrorMessage> errors() {
+ Set<ErrorMessage> s = new ArraySet<>(errors.size());
+ s.addAll(errors);
+ return s;
+ }
+
+ public String toString() {
+ return "Error: " + errors.get(0).toString();
+ }
+
+ /** Returns true - this is a meta hit containing information on other hits */
+ public boolean isMeta() {
+ return true;
+ }
+
+ /**
+ * Returns true if all errors in this have the given code
+ */
+ public boolean hasOnlyErrorCode(int code) {
+ for (ErrorMessage error : errors) {
+ if (error.getCode() != code)
+ return false;
+ }
+ return true;
+ }
+
+ public DefaultErrorHit clone() {
+ DefaultErrorHit clone = (DefaultErrorHit) super.clone();
+
+ clone.errors = new ArrayList<>(this.errors);
+ return clone;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/ErrorHit.java b/container-search/src/main/java/com/yahoo/search/result/ErrorHit.java
new file mode 100644
index 00000000000..a3b79d98e65
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/ErrorHit.java
@@ -0,0 +1,47 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Iterator;
+import java.util.Set;
+
+/**
+ * A hit which holds information on error conditions in a result.
+ * En error hit maintains a main error - the main error of the result.
+ *
+ * @author bratseth
+ */
+public interface ErrorHit extends Cloneable {
+
+ void setSource(String source);
+
+ /** Returns the main error of this result, never null */
+ @Deprecated // use: errors().iterator().next()
+ ErrorMessage getMainError();
+
+ /**
+ * Adds an error to this. This may change the main error
+ * and/or the list of detailed errors
+ */
+ void addError(ErrorMessage error);
+
+ /** Add all errors from another error hit to this */
+ void addErrors(ErrorHit errorHit);
+
+ /**
+ * Returns all the detail errors of this error hit, including the main error
+ */
+ Iterator<? extends ErrorMessage> errorIterator();
+
+ /** Returns a read-only set containing all the error of this, including the main error */
+ Set<ErrorMessage> errors();
+
+ /** Returns true - this is a meta hit containing information on other hits */
+ boolean isMeta();
+
+ /** Returns true if main error is the given error code or if main error
+ is general error 8 and all suberrors are the given error code */
+ boolean hasOnlyErrorCode(int code);
+
+ Object clone();
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/ErrorMessage.java b/container-search/src/main/java/com/yahoo/search/result/ErrorMessage.java
new file mode 100644
index 00000000000..0a0ef731836
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/ErrorMessage.java
@@ -0,0 +1,210 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.container.protect.Error;
+
+import static com.yahoo.container.protect.Error.*;
+
+
+/**
+ * An error message with a code. Use create methods to create messages.
+ * The identity of an error message is determined by its values.
+ *
+ * @author bratseth
+ */
+public class ErrorMessage extends com.yahoo.processing.request.ErrorMessage {
+
+ public static final int NULL_QUERY = Error.NULL_QUERY.code;
+
+ /** The source producing this error, not always set */
+ private String source = null;
+
+ public ErrorMessage(int code, String message) {
+ super(code,message);
+ }
+
+ /**
+ * Creates an application specific error message with an application specific code.
+ * If the error results from an exception a message which includes information from all nested (cause) exceptions
+ * can be generated using com.yahoo.protect.Exceptions.toMessageString(exception).
+ */
+ public ErrorMessage(int code, String message, String detailedMessage) {
+ super(code,message, detailedMessage);
+ }
+
+ /**
+ * Creates an application specific error message with an application specific code and a stack trace.
+ * This should only be used when there is useful information in the cause, i.e when the exception
+ * is not expected. Applications rarely need to handle unexpected exceptions as this is done by the framework.
+ */
+ public ErrorMessage(int code, String message, String detailedMessage, Throwable cause) {
+ super(code, message, detailedMessage, cause);
+ }
+
+ /** Creates an error message indicating that some backend service is unreachable */
+ public static ErrorMessage createNoBackendsInService(String detailedMessage) {
+ return new ErrorMessage(NO_BACKENDS_IN_SERVICE.code, "No backends in service. Try later", detailedMessage);
+ }
+
+ /** Creates an error message indicating that a null query was attempted evaluated */
+ public static ErrorMessage createNullQuery(String detailedMessage) {
+ return new ErrorMessage(NULL_QUERY, "Null query", detailedMessage);
+ }
+
+ /** Creates an error message indicating that the request is too large */
+ public static ErrorMessage createRequestTooLarge(String detailedMessage) {
+ return new ErrorMessage(REQUEST_TOO_LARGE.code, "Request too large", detailedMessage);
+ }
+
+ /** Creates an error message indicating that an illegal query was attempted evaluated. */
+ public static ErrorMessage createIllegalQuery(String detailedMessage) {
+ return new ErrorMessage(ILLEGAL_QUERY.code, "Illegal query", detailedMessage);
+ }
+
+ /** Creates an error message indicating that an invalid request parameter was received. */
+ public static ErrorMessage createInvalidQueryParameter(String detailedMessage) {
+ return new ErrorMessage(INVALID_QUERY_PARAMETER.code, "Invalid query parameter", detailedMessage);
+ }
+
+ /** Creates an error message indicating that an invalid request parameter was received. */
+ public static ErrorMessage createInvalidQueryParameter(String detailedMessage, Throwable cause) {
+ return new ErrorMessage(INVALID_QUERY_PARAMETER.code, "Invalid query parameter", detailedMessage, cause);
+ }
+
+ /** Creates a generic message used when there is no information available on the category of the error. */
+ public static ErrorMessage createUnspecifiedError(String detailedMessage) {
+ return new ErrorMessage(UNSPECIFIED.code, "Unspecified error", detailedMessage);
+ }
+
+ /** Creates a generic message used when there is no information available on the category of the error. */
+ public static ErrorMessage createUnspecifiedError(String detailedMessage, Throwable cause) {
+ return new ErrorMessage(UNSPECIFIED.code, "Unspecified error", detailedMessage, cause);
+ }
+
+ /** Creates a general error from an application components. */
+ public static ErrorMessage createErrorInPluginSearcher(String detailedMessage) {
+ return new ErrorMessage(ERROR_IN_PLUGIN.code, "Error in plugin Searcher", detailedMessage);
+ }
+
+ /** Creates a general error from an application component. */
+ public static ErrorMessage createErrorInPluginSearcher(String detailedMessage, Throwable cause) {
+ return new ErrorMessage(ERROR_IN_PLUGIN.code, "Error in plugin Searcher", detailedMessage, cause);
+ }
+
+ /** Creates an error indicating that an invalid query transformation was attempted. */
+ public static ErrorMessage createInvalidQueryTransformation(String detailedMessage) {
+ return new ErrorMessage(INVALID_QUERY_TRANSFORMATION.code, "Invalid query transformation",detailedMessage);
+ }
+
+ /** Creates an error indicating that the server is misconfigured */
+ public static ErrorMessage createServerIsMisconfigured(String detailedMessage) {
+ return new ErrorMessage(SERVER_IS_MISCONFIGURED.code, "Service is misconfigured", detailedMessage);
+ }
+
+ /** Creates an error indicating that there was a general error communicating with a backend service. */
+ public static ErrorMessage createBackendCommunicationError(String detailedMessage) {
+ return new ErrorMessage(BACKEND_COMMUNICATION_ERROR.code, "Backend communication error", detailedMessage);
+ }
+
+ /** Creates an error indicating that a node could not be pinged. */
+ public static ErrorMessage createNoAnswerWhenPingingNode(String detailedMessage) {
+ return new ErrorMessage(NO_ANSWER_WHEN_PINGING_NODE.code, "No answer when pinging node", detailedMessage);
+ }
+
+ public static final int timeoutCode = Error.TIMEOUT.code;
+ /** Creates an error indicating that a request to a backend timed out. */
+ public static ErrorMessage createTimeout(String detailedMessage) {
+ return new ErrorMessage(timeoutCode, "Timed out",detailedMessage);
+ }
+
+ public static final int emptyDocsumsCode = Error.EMPTY_DOCUMENTS.code;
+ /** Creates an error indicating that a request to a backend returned empty document content data. */
+ public static ErrorMessage createEmptyDocsums(String detailedMessage) {
+ return new ErrorMessage(emptyDocsumsCode, "Empty document summaries",detailedMessage);
+ }
+
+ /**
+ * Creates an error indicating that the requestor is not authorized to perform the requested operation.
+ * If this error is present, a HTTP layer will return 401.
+ */
+ public static ErrorMessage createUnauthorized(String detailedMessage) {
+ return new ErrorMessage(UNAUTHORIZED.code, "Client not authenticated.", detailedMessage);
+ }
+
+ /**
+ * Creates an error indicating that a forbidden operation was requested.
+ * If this error is present, a HTTP layer will return 403.
+ */
+ public static ErrorMessage createForbidden(String detailedMessage) {
+ return new ErrorMessage(FORBIDDEN.code, "Forbidden.", detailedMessage);
+ }
+
+ /**
+ * Creates an error indicating that the requested resource was not found.
+ * If this error is present, a HTTP layer will return 404.
+ */
+ public static ErrorMessage createNotFound(String detailedMessage) {
+ return new ErrorMessage(NOT_FOUND.code, "Resource not found.", detailedMessage);
+ }
+
+ /**
+ * Creates an error analog to HTTP bad request. If this error is present, a
+ * HTTP layer will return 400.
+ */
+ public static ErrorMessage createBadRequest(String detailedMessage) {
+ return new ErrorMessage(BAD_REQUEST.code, "Bad request.", detailedMessage);
+ }
+
+ /**
+ * Creates an error analog to HTTP internal server error. If this error is present, a
+ * HTTP layer will return 500.
+ */
+ public static ErrorMessage createInternalServerError(String detailedMessage) {
+ return new ErrorMessage(INTERNAL_SERVER_ERROR.code, "Internal server error.", detailedMessage);
+ }
+
+ /** Sets the source producing this error */
+ public void setSource(String source) { this.source = source; }
+
+ /** Returns the source producing this error, or null if no source is specified */
+ public String getSource() { return source; }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode() + (source == null ? 0 : 31 * source.hashCode());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!super.equals(o)) return false;
+
+ ErrorMessage other = (ErrorMessage) o;
+ if (this.source != null) {
+ if (!this.source.equals(other.source)) return false;
+ } else {
+ if (other.source != null) return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return (source==null ? "" : "Source '" + source + "': ") + super.toString();
+ }
+
+ @Override
+ public ErrorMessage clone() {
+ return (ErrorMessage)super.clone();
+ }
+
+ /**
+ * Returns the given error message as this type. If it already is, this is a cast of the given instance.
+ * Otherwise this creates a new instance having the same payload as the given instance.
+ */
+ public static ErrorMessage from(com.yahoo.processing.request.ErrorMessage error) {
+ if (error instanceof ErrorMessage) return (ErrorMessage)error;
+ return new ErrorMessage(error.getCode(),error.getMessage(),error.getDetailedMessage(),error.getCause());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/FeatureData.java b/container-search/src/main/java/com/yahoo/search/result/FeatureData.java
new file mode 100644
index 00000000000..5c57d21b455
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/FeatureData.java
@@ -0,0 +1,44 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.data.access.Inspector;
+import com.yahoo.data.access.Inspectable;
+import com.yahoo.data.access.Type;
+import com.yahoo.data.JsonProducer;
+import com.yahoo.data.access.simple.JsonRender;
+
+/**
+ * A wrapper for structured data representing feature values.
+ */
+public class FeatureData implements Inspectable, JsonProducer {
+
+ private final Inspector value;
+
+ public FeatureData(Inspector value) {
+ this.value = value;
+ }
+
+ @Override
+ public Inspector inspect() {
+ return value;
+ }
+
+ public String toString() {
+ if (value.type() == Type.EMPTY) {
+ return "";
+ } else {
+ return toJson();
+ }
+ }
+
+ @Override
+ public String toJson() {
+ return writeJson(new StringBuilder()).toString();
+ }
+
+ @Override
+ public StringBuilder writeJson(StringBuilder target) {
+ return JsonRender.render(value, target, true);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/FieldComparator.java b/container-search/src/main/java/com/yahoo/search/result/FieldComparator.java
new file mode 100644
index 00000000000..77f6db18745
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/FieldComparator.java
@@ -0,0 +1,106 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.search.query.Sorting;
+
+import java.util.Comparator;
+
+/**
+ * Comparator used for ordering hits using the field values and a sorting specification.
+ * <p>
+ * <b>Note:</b> this comparator imposes orderings that are inconsistent with equals.
+ * <p>
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+// Is tested in HitSortSpecOrdererTestCase
+public class FieldComparator extends ChainableComparator {
+
+ /** The definition of sorting order */
+ private Sorting sorting;
+
+ /** Creates a field comparator using a sort order and having no chained comparator */
+ public FieldComparator(Sorting sorting) {
+ this(sorting,null);
+ }
+
+ /** Creates a field comparator using a sort order with a chained comparator */
+ public FieldComparator(Sorting sorting,Comparator<Hit> secondaryComparator) {
+ super(secondaryComparator);
+ this.sorting = sorting;
+ }
+
+ /** Creates a comparator given a sorting, or returns null if the given sorting is null */
+ public static FieldComparator create(Sorting sorting) {
+ if (sorting==null) return null;
+ return new FieldComparator(sorting);
+ }
+
+ /**
+ * Compares hits based on a sorting specification and values
+ * stored in hit fields.0
+ * <p>
+ * When one of the hits has the requested property and the other
+ * has not, the the hit containing the property precedes the one
+ * that does not.
+ * <p>
+ * There is no locale based sorting here, as the backend does
+ * not do that either.
+ *
+ * @return -1, 0, 1 if first should be sorted before, equal to
+ * or after second
+ */
+ @Override
+ public int compare(Hit first, Hit second) {
+ for (Sorting.FieldOrder fieldOrder : sorting.fieldOrders() ) {
+ String fieldName = fieldOrder.getFieldName();
+ Object a = getField(first,fieldName);
+ Object b = getField(second,fieldName);
+
+ // If either of the values are null, don't touch the ordering
+ // This is to avoid problems if the sorting is called before the
+ // result is filled.
+ if ((a == null) || (b == null)) return 0;
+
+ int x = compareValues(a, b, fieldOrder.getSorter());
+ if (x != 0) {
+ if (fieldOrder.getSortOrder() == Sorting.Order.DESCENDING)
+ x *= -1;
+ return x;
+ }
+ }
+ return super.compare(first,second);
+ }
+
+ public Object getField(Hit hit,String key) {
+ if ("[relevance]".equals(key)) return hit.getRelevance();
+ if ("[rank]".equals(key)) return hit.getRelevance();
+ if ("[source]".equals(key)) return hit.getSource();
+ return hit.getField(key);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private int compareValues(Object first, Object second, Sorting.AttributeSorter s) {
+ if (first.getClass().isInstance(second)
+ && first instanceof Comparable) {
+ // We now know:
+ // second is of a type which is a subclass of first's type
+ // They both implement Comparable
+ return s.compare((Comparable)first, (Comparable)second);
+ } else {
+ return s.compare(first.toString(), second.toString());
+ }
+ }
+
+ public String toString() {
+ StringBuilder b = new StringBuilder();
+ b.append("FieldComparator:");
+ if (sorting == null) {
+ b.append(" null");
+ } else {
+ b.append(sorting.toString());
+ }
+ return b.toString();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/Hit.java b/container-search/src/main/java/com/yahoo/search/result/Hit.java
new file mode 100644
index 00000000000..2cf1dba7efd
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/Hit.java
@@ -0,0 +1,787 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.collections.ArraySet;
+import com.yahoo.component.provider.ListenableFreezableClass;
+import com.yahoo.net.URI;
+import com.yahoo.prelude.hitfield.HitField;
+import com.yahoo.prelude.hitfield.JSONString;
+import com.yahoo.prelude.hitfield.XMLString;
+import com.yahoo.processing.Request;
+import com.yahoo.processing.response.Data;
+import com.yahoo.search.Query;
+import com.yahoo.search.Searcher;
+import com.yahoo.text.XML;
+
+import java.util.*;
+
+/**
+ * <p>A search hit. The identifier of the hit is the uri
+ * (the uri is immutable once set).
+ * If two hits have the same uri they are equal per definition.
+ * Hits are naturally ordered by decreasing relevance.
+ * Note that this definition of equals and natural ordering is inconsistent.</p>
+ *
+ * <p>Hits may be of the <i>meta</i> type, meaning that they contain some information
+ * about the query or result which does not represent a particular piece of matched
+ * content. Meta hits are not counted in the hit count of the result, and should
+ * usually never be filtered out.</p>
+ *
+ * <p>Some hit sources may produce hits which are not <i>filled</i>. A non-filled
+ * hit may miss some or all of its property values. To fill those,
+ * {@link com.yahoo.search.Searcher#fill fill} must be called on the search chain by the searcher
+ * which requires those properties. This mechanism allows initial filtering to be
+ * done of a lightweight version of the hits, which is cheaper if a significant
+ * number of hits are filtered out.</p>
+ *
+ * @author bratseth
+ */
+public class Hit extends ListenableFreezableClass implements Data, Comparable<Hit>, Cloneable {
+
+ private static final String DOCUMENT_ID = "documentid";
+
+ /** A collection of string keyed object properties. */
+ private Map<String,Object> fields = null;
+ private Map<String,Object> unmodifiableFieldMap = null;
+
+ /** Meta data describing how a given searcher should treat this hit. */
+ // TODO: The case for this is to allow multiple levels of federation searcher routing.
+ // Replace this by a cleaner specific solution to that problem.
+ private Map<Searcher, Object> searcherSpecificMetaData;
+
+ /** The id of this hit */
+ private URI id;
+
+ /** The types of this hit */
+ private Set<String> types = new ArraySet<>(2);
+
+ /** The relevance of this hit */
+ private Relevance relevance;
+
+ /** Says whether this hit is cached or not */
+ private boolean cached = false;
+
+ /**
+ * The summary classes for which this hit is filled. If this set
+ * is 'null', it means that this hit is unfillable, which is
+ * equivalent to a hit where all summary classes have already
+ * been filled, or a hit where further filling will
+ * yield no extra information, if you prefer to look at it that
+ * way.
+ */
+ private Set<String> filled = null;
+ private Set<String> unmodifiableFilled = null;
+
+ /** The name of the source creating this hit */
+ private String source = null;
+
+ /**
+ * Add number, assigned when adding the hit to a result,
+ * used to order equal relevant hit by add order
+ */
+ private int addNumber = -1;
+ private int sourceNumber;
+
+ /** The query which produced this hit. Used for multi phase searching */
+ private Query query;
+
+ /**
+ * Set to true for hits which does not contain content,
+ * but which contains meta information about the query or result
+ */
+ private boolean meta=false;
+
+ /** If this is true, then this hit will not be counted as a concrete hit */
+ private boolean auxiliary=false;
+
+ /**
+ * The hit field used to store rank features. TODO: Remove
+ */
+ public static final String RANKFEATURES_FIELD = "rankfeatures";
+ public static final String SDDOCNAME_FIELD = "sddocname";
+
+ private Map<String,Object> getFieldMap() {
+ if (fields == null) {
+ fields = new LinkedHashMap<>(16);
+ }
+ return fields;
+ }
+
+ private Map<String,Object> getUnmodifiableFieldMap() {
+ if (unmodifiableFieldMap == null) {
+ if (fields == null) {
+ return Collections.emptyMap();
+ } else {
+ unmodifiableFieldMap = Collections.unmodifiableMap(fields);
+ }
+ }
+ return unmodifiableFieldMap;
+ }
+
+ public static String stripCharacter(char strip, String toStripFrom) {
+ StringBuilder builder = null;
+
+ int lastBadChar = 0;
+ for (int i = 0; i < toStripFrom.length(); i++) {
+ if (toStripFrom.charAt(i) == strip) {
+ if (builder == null) {
+ builder = new StringBuilder(toStripFrom.length());
+ }
+
+ builder.append(toStripFrom, lastBadChar, i);
+ lastBadChar = i + 1;
+ }
+ }
+
+ if (builder == null) {
+ return toStripFrom;
+ } else {
+ if (lastBadChar < toStripFrom.length()) {
+ builder.append(toStripFrom, lastBadChar, toStripFrom.length());
+ }
+
+ return builder.toString();
+ }
+ }
+
+ /** Creates an (invalid) empty hit. Id and relevance must be set before handoff */
+ protected Hit() {}
+
+ /**
+ * Creates a minimal valid hit having relevance 1000
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ */
+ public Hit(String id) {
+ this(id, 1);
+ }
+
+ /**
+ * Creates a minimal valid hit having relevance 1
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types referring to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param query the query having this as a hit
+ */
+ public Hit(String id, Query query) {
+ this(id, 1, query);
+ }
+
+ /**
+ * Creates a minimal valid hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types referring to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance a relevance measure, preferably normalized between 0 and 1
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1
+ */
+ public Hit(String id, double relevance) {
+ this(id,new Relevance(relevance));
+ }
+
+ /**
+ * Creates a minimal valid hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types referring to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance a relevance measure, preferably normalized between 0 and 1
+ * @param query the query having this as a hit
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1
+ */
+ public Hit(String id, double relevance, Query query) {
+ this(id,new Relevance(relevance),query);
+ }
+
+ /**
+ * Creates a minimal valid hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance the relevance of this hit
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1000
+ */
+ public Hit(String id, Relevance relevance) {
+ this(id, relevance, (String)null);
+ }
+
+ /**
+ * Creates a minimal valid hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance the relevance of this hit
+ * @param query the query having this as a hit
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1000
+ */
+ public Hit(String id, Relevance relevance, Query query) {
+ this(id, relevance,null, query);
+ }
+
+ /**
+ * Creates a hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance a relevance measure, preferably normalized between 0 and 1
+ * @param source the name of the source of this hit, or null if no source is being specified
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1000
+ */
+ public Hit(String id, double relevance, String source) {
+ this(id, new Relevance(relevance), source, null);
+ }
+
+ /**
+ * Creates a hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance a relevance measure, preferably normalized between 0 and 1
+ * @param source the name of the source of this hit, or null if no source is being specified
+ * @param query the query having this as a hit
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1000
+ */
+ public Hit(String id, double relevance, String source, Query query) {
+ this(id, new Relevance(relevance), source);
+ }
+
+ /**
+ * Creates a hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance the relevance of this hit
+ * @param source the name of the source of this hit
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1000
+ */
+ public Hit(String id, Relevance relevance, String source) {
+ this(id, relevance, source, null);
+ }
+
+ /**
+ * Creates a hit.
+ *
+ * @param id the URI of a hit. This should be unique for this hit (but not for this
+ * <i>object instance</i> of course). For hit types refering to resources,
+ * this will be the full, valid url of the resource, for self-contained hits
+ * it is simply any unique string identification
+ * @param relevance the relevance of this hit
+ * @param source the name of the source of this hit
+ * @param query the query having this as a hit
+ * @throws IllegalArgumentException if the given relevance is not between 0 and 1000
+ */
+ public Hit(String id, Relevance relevance, String source, Query query) {
+ this.id=new URI(id);
+ this.relevance = relevance;
+ this.source=source;
+ this.query = query;
+ }
+
+ /** Calls setId(new URI(id)) */
+ public void setId(String id) {
+ if (this.id!=null) throw new IllegalStateException("Attempt to change id of " + this + " to " + id);
+ if (id==null) throw new NullPointerException("Attempt to assign id of " + this + " to null");
+ assignId(new URI(id));
+ }
+
+
+ /**
+ * Initializes the id of this hit.
+ *
+ * @throws NullPointerException if the uri is null
+ * @throws IllegalStateException if the uri of this hit is already set
+ */
+ public void setId(URI id) {
+ if (this.id!=null) throw new IllegalStateException("Attempt to change id of " + this + " to " + id);
+ assignId(id);
+ }
+
+ /**
+ * Assigns a new or changed id to this hit.
+ * As this is protected, reassigning isn't legal for Hits by default, however, subclasses may allow it
+ * using this method.
+ */
+ protected final void assignId(URI id) {
+ if (id==null) throw new NullPointerException("Attempt to assign id of " + this + " to null");
+ this.id=id;
+ }
+
+ /** Returns the hit id */
+ public URI getId() { return id; }
+
+ /**
+ * Returns the id to display, or null to not display (render) the id.
+ * This is useful to avoid displaying ids when they are not assigned explicitly
+ * but are just generated values for internal use.
+ * This default implementation returns {@link #getId()}.toString()
+ */
+ public String getDisplayId() {
+ String id = null;
+
+ Object idField = getField(DOCUMENT_ID);
+ if (idField != null) {
+ id = idField.toString();
+ }
+ if (id == null) {
+ id = getId() == null ? null : getId().toString();
+ }
+ return id;
+ }
+
+ /**
+ * Sets the relevance of this hit
+ *
+ * @param relevance the relevance of this hit
+ */
+ public void setRelevance(Relevance relevance) {
+ if (relevance==null) throw new NullPointerException("Cannot assign null as relevance");
+ this.relevance = relevance;
+ }
+
+ /** Does setRelevance(new Relevance(relevance) */
+ public void setRelevance(double relevance) {
+ setRelevance(new Relevance(relevance));
+ }
+
+
+ /** Returns the relevance of this hit */
+ public Relevance getRelevance() { return relevance; }
+
+ /** Sets whether this hit is returned from a cache. Default is false */
+ public void setCached(boolean cached) { this.cached = cached; }
+
+ /** Returns whether this hit was added to this result from a cache or not */
+ public boolean isCached() { return cached; }
+
+ /**
+ * Tag this hit as fillable. This means that additional properties
+ * for this hit may be obtained by fetching document
+ * summaries. This also enables tracking of which summary classes
+ * have been used for filling so far. Invoking this method
+ * multiple times is allowed and will have no addition
+ * effect. Note that a fillable hit may not be made unfillable.
+ **/
+ public void setFillable() {
+ if (filled == null) {
+ filled = Collections.emptySet();
+ unmodifiableFilled = filled;
+ }
+ }
+
+ /**
+ * Register that this hit has been filled with properties using
+ * the given summary class. Note that this method will implicitly
+ * tag this hit as fillable if it is currently not.
+ *
+ * @param summaryClass summary class used for filling
+ **/
+ public void setFilled(String summaryClass) {
+ if (filled == null || filled.size() == 0) {
+ filled = Collections.singleton(summaryClass);
+ unmodifiableFilled = filled;
+ } else if (filled.size() == 1) {
+ filled = new HashSet<>(filled);
+ unmodifiableFilled = Collections.unmodifiableSet(filled);
+
+ filled.add(summaryClass);
+ } else {
+ filled.add(summaryClass);
+ }
+ }
+
+ public boolean isFillable() {
+ return filled != null;
+ }
+
+ /**
+ * Returns the set of summary classes for which this hit is
+ * filled as an unmodifiable set. If this set is 'null', it means that this hit is
+ * unfillable, which is equivalent with a hit where all summary
+ * classes have already been used for filling, or a hit where
+ * further filling will yield no extra information, if you prefer
+ * to look at it that way.
+ *
+ * Note that you might need to overload isFilled if you overload this one.
+ **/
+ public Set<String> getFilled() {
+ return unmodifiableFilled;
+ }
+
+ /**
+ * Returns whether this hit has been filled with the properties
+ * contained in the given summary class. Note that this method
+ * will also return true if this hit is not fillable.
+ */
+ public boolean isFilled(String summaryClass) {
+ return (filled == null) || filled.contains(summaryClass);
+ }
+
+ /** Sets the name of the source creating this hit */
+ public void setSource(String source) { this.source = source; }
+
+ /** Returns the name of the source creating this hit */
+ public String getSource() { return source; }
+
+ /** Returns the fields of this as a read-only map. This is more costly than the preferred iterator(), as
+ * it uses Collections.unmodifiableMap()
+ * @return An readonly map of the fields
+ **/
+ //TODO Should it be deprecated ?
+ public final Map<String,Object> fields() { return getUnmodifiableFieldMap(); }
+
+ /**
+ * Fields
+ * @return An iterator for traversing the fields
+ * @since 5.1.3
+ */
+ public final Iterator<Map.Entry<String,Object>> fieldIterator() { return getFieldMap().entrySet().iterator(); }
+
+ /** Returns a field value */
+ public Object getField(String value) { return fields != null ? fields.get(value) : null; }
+
+ /**
+ * Generate a HitField from a field if the field exists. Does the
+ * same as getField() in earlier versions.
+ *
+ * @since 3.0
+ */
+ public HitField buildHitField(String key) {
+ return buildHitField(key, false);
+ }
+
+ /**
+ * Generate a HitField from a field if the field exists. Does the
+ * same as getField() in earlier versions.
+ *
+ * @since 3.0
+ */
+ public HitField buildHitField(String key, boolean forceNoPreTokenize) {
+ return buildHitField(key, forceNoPreTokenize, false);
+ }
+
+ public HitField buildHitField(String key, boolean forceNoPreTokenize, boolean forceStringHandling) {
+ Object o = getField(key);
+ if (o == null) {
+ return null;
+ }
+
+ if (o instanceof HitField) {
+ return (HitField) o;
+ }
+
+ HitField h;
+ if (forceNoPreTokenize) {
+ if (o instanceof XMLString && !forceStringHandling) {
+ h = new HitField(key, (XMLString) o, false);
+ } else {
+ h = new HitField(key, o.toString(), false);
+ }
+ } else {
+ if (o instanceof XMLString && !forceStringHandling) {
+ h = new HitField(key, (XMLString) o);
+ } else {
+ h = new HitField(key, o.toString());
+ }
+ }
+ h.setOriginal(o);
+ getFieldMap().put(key, h);
+ return h;
+ }
+
+ /**
+ * Sets the value of a field
+ *
+ * @return the previous value, or null if none
+ */
+ public Object setField(String key, Object value) {
+ return getFieldMap().put(key, value);
+ }
+
+ /** Returns the types of this as a modifiable set. Modifications to this set are directly reflected in this hit */
+ public Set<String> types() { return types; }
+
+ /**
+ * Returns all types of this hit as a space-separated string
+ *
+ * @return all the types of this hit on the form "type1 type2 type3"
+ * (in no particular order). An empty string (never null) if
+ * no types are added
+ */
+ public String getTypeString() {
+ StringBuilder buffer = new StringBuilder(types.size() * 7);
+
+ for (Iterator<String> i = types.iterator(); i.hasNext();) {
+ buffer.append(i.next());
+ if (i.hasNext())
+ buffer.append(" ");
+ }
+ return buffer.toString();
+ }
+
+ /**
+ * Returns true if the argument is a hit having the same uri as this
+ */
+ public boolean equals(Object object) {
+ if (!(object instanceof Hit)) {
+ return false;
+ }
+ return getId().equals(((Hit) object).getId());
+ }
+
+ /**
+ * Returns the hashCode of this hit, which is the hashcode of its uri.
+ */
+ public int hashCode() {
+ if (getId() == null)
+ throw new IllegalStateException("Id has not been set.");
+
+ return getId().hashCode();
+ }
+
+ /** Compares this hit to another hit */
+ public int compareTo(Hit other) {
+ // higher relevance is better
+ int result = other.getRelevance().compareTo(getRelevance());
+ if (result != 0) {
+ return result;
+ }
+ // lower addnumber is better
+ result = this.getAddNumber() - other.getAddNumber();
+ if (result != 0) {
+ return result;
+ }
+
+ // if all else fails, compare URIs (alphabetically)
+ if (this.getId() == null && other.getId() == null) {
+ return 0;
+ } else if (other.getId() == null) {
+ return -1;
+ } else if (this.getId() == null) {
+ return 1;
+ } else {
+ return this.getId().compareTo(other.getId());
+ }
+ }
+
+ /**
+ * Returns the add number, assigned when adding the hit to a Result.
+ *
+ * Used to order equal relevant hit by add order. -1 if this hit
+ * has never been added to a result.
+ */
+ public int getAddNumber() { return addNumber; }
+
+ /**
+ * Sets the add number, assigned when adding the hit to a Result,
+ * used to order equal relevant hit by add order
+ */
+ public void setAddNumber(int addNumber) { this.addNumber = addNumber; }
+
+ /**
+ * Returns whether this is a concrete hit, containing content of the requested
+ * kind, or a meta hit containing information on the collection of hits,
+ * the query, the service and so on. This default implementation return false.
+ */
+ public boolean isMeta() { return meta; }
+
+ public void setMeta(boolean meta) { this.meta=meta; }
+
+ /**
+ * Auxiliary hits are not counted towards the concrete number of hits to satisfy in the users request.
+ * Any kind of meta hit is auxiliary, but hits containing concrete results can also be auxiliary,
+ * for example ads in a service which does not primarily serve ads, or groups in a hierarchical organization.
+ *
+ * @return true if the auxiliary value is true, or if this is a meta hit
+ */
+ public boolean isAuxiliary() {
+ return isMeta() || auxiliary;
+ }
+
+ public void setAuxiliary(boolean auxiliary) { this.auxiliary=auxiliary; }
+
+ /** Removes all fields from this */
+ public void clearFields() {
+ getFieldMap().clear();
+ }
+
+ /** Removes a field from this */
+ public Object removeField(String field) {
+ return getFieldMap().remove(field);
+ }
+
+ /**
+ * Returns the keys of the fields of this hit as a modifiable view.
+ * This follows the rules of key sets returned from maps: Key removals are reflected
+ * in the map, add and addAll is not supported.
+ */
+ public Set<String> fieldKeys() {
+ return getFieldMap().keySet();
+ }
+
+ /**
+ * Changes the key under which a value is found. This is useful because it allows keys to be changed
+ * without accessing the value (which may be lazily created).
+ */
+ public void changeFieldKey(String oldKey,String newKey) {
+ Map<String,Object> fieldMap = getFieldMap();
+ Object value=fieldMap.remove(oldKey);
+ fieldMap.put(newKey,value);
+ }
+
+ /**
+ * Returns a string describing this hit
+ */
+ public String toString() {
+ return "hit " + getId() + " (relevance " + getRelevance() + ")";
+ }
+
+ public Hit clone() {
+ Hit hit = (Hit) super.clone();
+
+ hit.fields = fields != null ? new LinkedHashMap<>(fields) : null;
+ hit.unmodifiableFieldMap = null;
+ hit.types = new LinkedHashSet<>(types);
+ if (filled != null) {
+ hit.setFilledInternal(new HashSet<>(filled));
+ }
+
+ return hit;
+ }
+
+ public int getSourceNumber() { return sourceNumber; }
+
+ public void setSourceNumber(int number) { this.sourceNumber = number; }
+
+ /** Returns the query which produced this hit, or null if not known */
+ public Query getQuery() { return query; }
+
+ public Request request() { return query; }
+
+ // TODO: rethink hit tagging
+ // hit group -> need option to retag
+ // hit -> should only set query once
+ public final void setQuery(Query query) {
+ if (this.query == null || this instanceof HitGroup) {
+ this.query = query;
+ }
+ }
+
+ // TODO: Deprecate
+ /**
+ * Returns a field of this hit XML escaped and without token
+ * delimiters.
+ *
+ * @return a field of this hit, or null if the property is not set
+ */
+ public String getFieldXML(String key) {
+ Object p = getField(key);
+
+ if (p == null) {
+ return null;
+ } else if (p instanceof HitField) {
+ HitField hf = (HitField) p;
+
+ return hf.quotedContent(false);
+ } else if (p instanceof StructuredData) {
+ return p.toString();
+ } else if (p instanceof XMLString || p instanceof JSONString) {
+ return p.toString();
+ } else {
+ return XML.xmlEscape(p.toString(), false, '\u001f');
+ }
+ }
+
+ // TODO: Move out? If not, delegate here from subclass
+ /**
+ * @return a field without bolding markup
+ */
+ public String getUnboldedField(String key, boolean escape) {
+ Object p = getField(key);
+
+ if (p == null) {
+ return null;
+ } else if (p instanceof HitField) {
+ return ((HitField) p).bareContent(escape, false);
+ } else if (p instanceof StructuredData) {
+ return p.toString();
+ } else if (p instanceof XMLString || p instanceof JSONString) {
+ return p.toString();
+ } else if (escape) {
+ return XML.xmlEscape(p.toString(), false, '\u001f');
+ } else {
+ return stripCharacter('\u001F', p.toString());
+ }
+ }
+
+ /**
+ * set meta data describing how a given searcher should treat this hit.
+ * It is currently recommended that the invoker == searcher.
+ * <b>Internal. Do not use!</b>
+ */
+ public void setSearcherSpecificMetaData(Searcher searcher, Object data) {
+ if (searcherSpecificMetaData == null) {
+ searcherSpecificMetaData = Collections.singletonMap(searcher, data);
+ } else {
+ if (searcherSpecificMetaData.size() == 1) {
+ Object tmp = searcherSpecificMetaData.get(searcher);
+ if (tmp != null) {
+ searcherSpecificMetaData = Collections.singletonMap(searcher, data);
+ } else {
+ searcherSpecificMetaData = new TreeMap<>(searcherSpecificMetaData);
+ searcherSpecificMetaData.put(searcher, data);
+ }
+ } else {
+ searcherSpecificMetaData.put(searcher, data);
+ }
+ }
+ }
+
+ /**
+ * get meta data describing how a given searcher should treat this hit.
+ * It is currently recommended that the invoker == searcher
+ * <b>Internal. Do not use!</b>
+ */
+ public Object getSearcherSpecificMetaData(Searcher searcher) {
+ return searcherSpecificMetaData != null ? searcherSpecificMetaData.get(searcher) : null;
+ }
+
+ /**
+ * For vespa internal use only.
+ * This is only for the ones specially interested. It will replace the backing
+ * for filled.
+ * @param filled the backing set
+ */
+ protected final void setFilledInternal(Set<String> filled) {
+ this.filled = filled;
+ unmodifiableFilled = (filled != null) ? Collections.unmodifiableSet(filled) : null;
+ }
+
+ /**
+ * For vespa internal use only.
+ * Gives access to the modifiable backing set of filled summaries.
+ * This set might be unmodifiable if the size is less than or equal to 1
+ * @return the set of filled summaries.
+ */
+ protected final Set<String> getFilledInternal() {
+ return filled;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/HitGroup.java b/container-search/src/main/java/com/yahoo/search/result/HitGroup.java
new file mode 100644
index 00000000000..e58c3dc847e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/HitGroup.java
@@ -0,0 +1,898 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.yahoo.collections.ListenableArrayList;
+import com.yahoo.net.URI;
+import com.yahoo.processing.response.ArrayDataList;
+import com.yahoo.processing.response.DataList;
+import com.yahoo.processing.response.DefaultIncomingData;
+import com.yahoo.processing.response.IncomingData;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+import static com.yahoo.collections.CollectionUtil.first;
+
+/**
+ * <p>A group of ordered hits. Since hitGroup is itself a kind of Hit,
+ * this can compose hierarchies of grouped hits.</p>
+ *
+ * <p>Group hits has a relevancy just as other hits - they can be ordered
+ * between each other and in comparison to other hits.
+ *
+ * <p>Note that a group is by default a meta hit, but it can also contain its own content
+ * in addition to subgroup content, in which case it should be set to non-meta.</p>
+ *
+ * @author bratseth
+ */
+public class HitGroup extends Hit implements DataList<Hit>, Cloneable, Iterable<Hit> {
+
+ // This does its own book-keeping of its various state variables
+ // (see methods towards the end). For state variables which are recursive
+ // (depending on the state of hits in subgroups), the strategy is to do
+ // book-keeping on only this immediate level, but not do recursive calls to
+ // find the true recursive state when queried. This is sort of a middle ground
+ // between handling the complexity of recursive state book-keeping and the
+ // query cost of not doing any book-keeping.
+ // There is also a method, analyse which recursively updates the recursive
+ // state of the group and all subgroups. This should be called if the hits
+ // may have changed their own state in a way that may impact the recursive
+ // state of this.
+
+ private ListenableArrayList<Hit> hits = new ListenableArrayList<>(16);
+
+ transient private List<Hit> unmodifiableHits = Collections.unmodifiableList(hits);
+
+ /** Whether or not the hits are sorted */
+ private boolean hitsSorted = true;
+
+ /** Whether or not deletion of hits breaks the sorted ordering */
+ private boolean deletionBreaksOrdering = false;
+
+ /** Whether the hits should be sorted (again) */
+ private boolean orderedHits = false;
+
+ /** The current number of concrete (non-meta) hits in the result */
+ private int concreteHitCount = 0;
+
+ /** The class used to determine the ordering of the hits of this */
+ transient private HitOrderer hitOrderer = null;
+
+ /** Accounting the number of subgroups to allow some early returns when the number is 0 */
+ private int subgroupCount=0;
+
+ /**
+ * The number of hits not cached at this level, not counting hits in subgroups or
+ * any nested hitgroups themselves
+ */
+ private int notCachedCount=0;
+
+ /**
+ * A direct reference to the errors of this result, or null if there are no errors.
+ * The error hit will also be listed in the set of this of this result
+ */
+ private ErrorHit errorHit = null;
+
+ private final ListenableFuture<DataList<Hit>> completedFuture;
+
+ private final IncomingData<Hit> incomingHits;
+
+ /** Creates an invalid group of hits. Id must be set before handoff. */
+ public HitGroup() {
+ incomingHits = new IncomingData.NullIncomingData<>(this);
+ setRelevance(new Relevance(1));
+ setMeta(true);
+ completedFuture = new IncomingData.NullIncomingData.ImmediateFuture<>(this);
+ }
+
+ /**
+ * Creates a hit group with max relevancy (1)
+ *
+ * @param id the id of this hit - any string, it is convenient to make this unique in the result containing this
+ */
+ public HitGroup(String id) {
+ this(id,new Relevance(1));
+ }
+
+ /**
+ * Creates a hit group
+ *
+ * @param id the id of this hit - any string, it is convenient to make this unique in the result containing this
+ * @param relevance the relevance of this group of hits, preferably a number between 0 and 1
+ */
+ public HitGroup(String id,double relevance) {
+ this(id,new Relevance(relevance));
+ }
+
+ /**
+ * Creates a group hit
+ *
+ * @param id the id of this hit - any string, it is convenient to make this unique in the result containing this
+ * @param relevance the relevancy of this group of hits
+ */
+ public HitGroup(String id, Relevance relevance) {
+ super(id, relevance);
+ this.incomingHits = new IncomingData.NullIncomingData<>(this);
+ setMeta(true);
+ completedFuture = new IncomingData.NullIncomingData.ImmediateFuture<>(this);
+ }
+
+ /**
+ * Creates a group hit
+ *
+ * @param id the id of this hit - any string, it is convenient to make this unique in the result containing this
+ * @param relevance the relevancy of this group of hits
+ * @param incomingHits the incoming buffer to which new hits can be added asynchronously
+ */
+ protected HitGroup(String id, Relevance relevance, IncomingData<Hit> incomingHits) {
+ super(id, relevance);
+ this.incomingHits = incomingHits;
+ setMeta(true);
+ completedFuture = new ArrayDataList.DrainOnGetFuture<>(this);
+ }
+
+ /**
+ * Creates a HitGroup which contains data which arrives in the future.
+ *
+ * @param id the id of this
+ * @return a HitGroup which is incomplete and which has an {@link #incoming} where new hits can be added later
+ */
+ public static HitGroup createAsync(String id) {
+ DefaultIncomingData<Hit> incomingData = new DefaultIncomingData<>();
+ HitGroup hitGroup = new HitGroup(id, new Relevance(1), incomingData);
+ incomingData.assignOwner(hitGroup);
+ return hitGroup;
+ }
+
+ /** Calls setId(new URI(id)) */
+ @Override
+ public void setId(String id) {
+ setId(new URI(id));
+ }
+
+ /**
+ * Assign an id to this hit.
+ * For HitGroups, this is a legal call also when an id is already set,
+ * i.e hit groups allows their ids to be reassigned.
+ * This is to allow hit groups to be inserted in new structures with an id reflecting their
+ * role/placement in the structure.
+ *
+ * @param id the new or initial iof of this hit
+ */
+ @Override
+ public void setId(URI id) {
+ super.assignId(id);
+ }
+
+ /**
+ * Turn off internal resorting of hits.
+ *
+ * @param ordered set to true to tell this group that the hits set in it is already correctly ordered and should
+ * never be resorted. Set to false to use the default lazy resorting by hit ordering.
+ */
+ public void setOrdered(boolean ordered) { this.orderedHits = ordered; }
+
+ /**
+ * Returns the number of hits available immediately in this group
+ * (counting a subgroup as one hit).
+ */
+ public int size() {
+ return hits.size();
+ }
+
+ /**
+ * <p>Returns the number of concrete hits contained in this group
+ * and all subgroups. This should equal the
+ * requested hits count if the query has that many matches.</p>
+ */
+ public int getConcreteSize() {
+ if (subgroupCount<1) return concreteHitCount;
+ int recursiveConcreteCount=concreteHitCount;
+ for (Hit hit : hits) {
+ if (hit instanceof HitGroup)
+ recursiveConcreteCount+=((HitGroup)hit).getConcreteSize();
+ }
+ return recursiveConcreteCount;
+ }
+
+ /**
+ * <p>Returns the number of concrete hits contained in <i>this</i> group,
+ * without counting hits in subgroups.
+ */
+ public int getConcreteSizeShallow() { return concreteHitCount; }
+
+ /**
+ * Returns the number of HitGroups present immediately in this list of hits.
+ */
+ public int getSubgroupCount() { return subgroupCount; }
+
+ /**
+ * Adds a hit to this group.
+ * If the given hit is an ErrorHit and this group already have an error hit,
+ * the errors in the given hit are merged into the errors of this.
+ *
+ * @return the resulting hit - this is usually the input hit, but if an error hit was added,
+ * and there was already an error hit present, that hit, containing the merged information
+ * is returned
+ */
+ @Override
+ public Hit add(Hit hit) {
+ if (hit.isMeta() && hit instanceof ErrorHit) {
+ boolean add = mergeErrors((ErrorHit) hit);
+ if (!add) return (Hit)errorHit;
+ }
+ handleNewHit(hit);
+ hits.add(hit);
+ return hit;
+ }
+
+ /**
+ * Adds a list of hits to this group, the same
+ */
+ public void addAll(List<Hit> hits) {
+ for (Hit hit : hits)
+ add(hit);
+ }
+
+ /**
+ * Returns the hit at the given (0-base) index in this group of hit
+ * (without searching any subgroups).
+ *
+ * @param index the index into this list
+ * @throws IndexOutOfBoundsException if there is no hit at the given index
+ */
+ public Hit get(int index) {
+ updateHits();
+ ensureSorted();
+ return hits.get(index);
+ }
+
+ /** Same as {@link #get(String,int)} */
+ public Hit get(String id) {
+ return get(id,-1);
+ }
+
+ public Hit get(String id, int depth) {
+ return get(new URI(id), depth);
+ }
+
+ /**
+ * Returns the hit with the given id, or null if there is no hit with this id
+ * in this group or any subgroup.
+ * This method is o(min(number of nested hits in this result,depth)).
+ *
+ * @param id the id of the hit to return from this or any nested group
+ * @param depth the max depth to recurse into nested groups: -1: Recurse infinitely deep, 0: Only look at hits in
+ * the list of this group, 1: Look at hits in this group, and the hits of any immediate nested HitGroups,
+ * etc.
+ * @return The hit, or null if not found.
+ */
+ public Hit get(URI id, int depth) {
+ updateHits();
+ for (Iterator<Hit> i = unorderedIterator(); i.hasNext();) {
+ Hit hit = i.next();
+ URI hitUri = hit.getId();
+
+ if (hitUri != null && hitUri.equals(id)) {
+ return hit;
+ }
+
+ if (hit instanceof HitGroup && depth!=0) {
+ Hit found=((HitGroup)hit).get(id,depth-1);
+ if (found!=null) return found;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Inserts the given hit at the specified index in this group.
+ */
+ public void set(int index, Hit hit) {
+ updateHits();
+ if (hit instanceof ErrorHit) { // Merge instead
+ add(hit);
+ return;
+ }
+
+ handleNewHit(hit);
+ Hit oldHit = hits.set(index, hit);
+
+ if (oldHit!=null)
+ handleRemovedHit(oldHit);
+ }
+
+ /**
+ * Adds a hit to this group in the specified index,
+ * all existing hits on this index and higher will have their index
+ * increased by one.
+ * <b>Note:</b> If the group was sorted, it will still be considered sorted
+ * after this call.
+ */
+ public void add(int index, Hit hit) {
+ if (hit instanceof ErrorHit) { // Merge instead
+ add(hit);
+ return;
+ }
+
+ boolean wasSorted = hitsSorted;
+ handleNewHit(hit);
+ hits.add(index, hit);
+ hitsSorted = wasSorted;
+ }
+
+ /**
+ * Removes a hit from this group or any subgroup
+ *
+ * @param uriString the uri of the hit to remove
+ * @return the hit to remove, or null if the hit was not present
+ */
+ public Hit remove(String uriString) {
+ return remove(new URI(uriString));
+ }
+
+ /**
+ * Removes a hit from this group or any subgroup.
+ *
+ * @param uri The uri of the hit to remove.
+ * @return The hit removed, or null if not found.
+ */
+ public Hit remove(URI uri) {
+ for (Iterator<Hit> it = hits.iterator(); it.hasNext(); ) {
+ Hit hit = it.next();
+ if (uri.equals(hit.getId())) {
+ it.remove();
+ handleRemovedHit(hit);
+ return hit;
+ }
+ if (hit instanceof HitGroup) {
+ Hit removed = ((HitGroup)hit).remove(uri);
+ if (removed != null) {
+ return removed;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Removes a hit from this group (not considering the hits of any subgroup)
+ *
+ * @param index the position of the hit to remove
+ * @return the hit removed
+ * @throws IndexOutOfBoundsException if there is no hit at the given position
+ */
+ public Hit remove(int index) {
+ updateHits();
+ Hit hit = hits.remove(index);
+ handleRemovedHit(hit);
+
+ return hit;
+ }
+
+ /** Sets the main error of this result. Prefer addError to add some error information. */
+ public void setError(ErrorMessage error) {
+ if (errorHit == null)
+ add((Hit)createErrorHit(error));
+ else
+ errorHit.addError(error);
+ }
+
+ /** Adds an error to this result */
+ public void addError(ErrorMessage error) {
+ if (errorHit == null)
+ add((Hit)createErrorHit(error));
+ else
+ errorHit.addError(error);
+ }
+
+ /**
+ * Returns the error hit containing all error information,
+ * or null if no error has occurred
+ */
+ public ErrorHit getErrorHit() {
+ getError(); // Make sure the error hit is updated
+ return errorHit;
+ }
+
+ /**
+ * Returns the first error in this result,
+ * or null if no searcher has produced an error AND the query doesn't contain an error
+ */
+ public ErrorMessage getError() {
+ // See updateHits if this method is changed
+ if (errorHit != null) {
+ return errorHit.errors().iterator().next();
+ }
+
+ if (getQuery() != null && getQuery().errors().size() != 0) {
+ updateHits();
+ } // Pull them over
+
+ if (errorHit == null) {
+ return null;
+ }
+
+ return errorHit.errors().iterator().next();
+ }
+
+ /**
+ * Handles the addition of a new error hit, whether or not we already have one
+ *
+ * @return true if this shouls also be added to the list of hits of this reslt
+ */
+ private boolean mergeErrors(ErrorHit newHit) {
+ if (errorHit == null) {
+ errorHit = newHit;
+ return true;
+ } else {
+ errorHit.addErrors(newHit);
+ return false;
+ }
+ }
+
+ /**
+ * Must be called before the list of hits, or anything dependent on the list of hits, is removed.
+ * Merges errors from the query if there is one set for this group
+ */
+ private void updateHits() {
+ if (getQuery()==null) return;
+
+ if (getQuery().errors().size() == 0) return;
+
+ if (errorHit == null) // Creates an error hit where the first error is "main"
+ add((Hit)createErrorHit(toSearchError(getQuery().errors().get(0))));
+
+ // Add the rest of the errors
+ for (int i=1; i<getQuery().errors().size(); i++)
+ errorHit.addError(toSearchError(getQuery().errors().get(i)));
+ getQuery().errors().clear(); // TODO: Really clear them from here?
+ }
+
+ protected ErrorHit createErrorHit(ErrorMessage errorMessage) {
+ return new DefaultErrorHit(getSource(), errorMessage);
+ }
+
+ /** Compatibility */
+ private ErrorMessage toSearchError(com.yahoo.processing.request.ErrorMessage error) {
+ if (error instanceof ErrorMessage) return (ErrorMessage)error;
+ else return new ErrorMessage(error.getCode(),error.getMessage(),error.getDetailedMessage(),error.getCause());
+ }
+
+ /**
+ * Remove the first <code>offset</code> <i>concrete</i> hits in this group,
+ * and hits beyond <code>offset+numHits</code>
+ */
+ public void trim(int offset, int numHits) {
+ updateHits();
+ ensureSorted();
+
+ int highBound = numHits + offset; // Largest offset +1
+
+ int currentIndex = -1;
+
+ for (Iterator<Hit> i = hits.iterator(); i.hasNext();) {
+ Hit hit = i.next();
+
+ if (hit.isAuxiliary()) continue;
+
+ currentIndex++;
+ if (currentIndex < offset || currentIndex >= highBound) {
+ i.remove();
+ handleRemovedHit(hit);
+ }
+ }
+ }
+
+ /**
+ * Returns an iterator of the hits in this group.
+ * <p>
+ * This iterator is modifiable - removals will take effect in this group of hits.
+ */
+ public Iterator<Hit> iterator() {
+ updateHits();
+ ensureSorted();
+ return new HitIterator(this, hits);
+ }
+
+ /**
+ * Returns an iterator that does depth-first traversal of leaf hits of this group. Calling this method has the
+ * side-effect of sorting the internal list of hits.
+ *
+ * @return A modifiable iterator.
+ */
+ public Iterator<Hit> deepIterator() {
+ return new DeepHitIterator(iterator(), true);
+ }
+
+ /**
+ * Returns an iterator that does depth-first traversal of leaf hits of this group, in a potentially unsorted order.
+ * As opposed to {@link #deepIterator()}, this method has no side-effect.
+ *
+ * @return A modifiable iterator.
+ */
+ public Iterator<Hit> unorderedDeepIterator() {
+ return new DeepHitIterator(unorderedIterator(), false);
+ }
+
+ /** Returns a read only list view of the hits in this */
+ public List<Hit> asList() {
+ updateHits();
+ ensureSorted();
+ return unmodifiableHits;
+ }
+
+ /**
+ * Returns a read only list view of the hits in this which is potentially unsorted.
+ * Using this over getHits is potentially faster when a sorted view is not needed.
+ */
+ public List<Hit> asUnorderedHits() {
+ updateHits();
+ return unmodifiableHits;
+ }
+
+ /**
+ * Returns an iterator of the hits in this group in a potentially unsorted order.
+ * <p>
+ * Using this over getPreludeHitIterator is potentially faster when a sorted view is not needed.
+ * <p>
+ * This iterator is modifiable - removals will take effect in this group of hits.
+ */
+ public Iterator<Hit> unorderedIterator() {
+ updateHits();
+ return new HitIterator(this, hits);
+ }
+
+ /**
+ * Force hit sorting now.
+ * This is not normally useful because a group will stay sorted automatically,
+ * but it is in the case where
+ * the hits have changed their internal state in a way that should change ordering
+ */
+ public void sort() {
+ if (hitOrderer == null) {
+ Collections.sort(hits);
+ hitsSorted = true;
+ } else {
+ // This may or may not lead to a sorted result set, but
+ // it's a best effort
+ hitOrderer.order(hits);
+ if (likelyHitsHaveCorrectValueForSortFields()) {
+ hitsSorted = true;
+ }
+ }
+ }
+
+ private boolean likelyHitsHaveCorrectValueForSortFields() {
+ if (hitOrderer == null) {
+ return true;
+ } else {
+ Set<String> filledFields = getFilled();
+ return filledFields == null || !filledFields.isEmpty();
+ }
+ }
+
+ /**
+ * <p>Sets the hit orderer for this group.</p>
+ *
+ * @param hitOrderer the new hit orderer, or null to use default relevancy ordering
+ */
+ public void setOrderer(HitOrderer hitOrderer) {
+ this.hitOrderer = hitOrderer;
+ if (hits.size() > 1) {
+ hitsSorted = false;
+ }
+ }
+
+ /**
+ * Explicitly set whether the hits in this group are correctly sorted at this moment.
+ * If the contained hits are modified directly in a way that
+ * may break ordering, you should call setSorted(false).
+ */
+ public void setSorted(boolean sorted) {
+ this.hitsSorted = sorted;
+ }
+
+
+ /** Returns the orderer used by this group, or null if the default relevancy order is used */
+ public HitOrderer getOrderer() {
+ return hitOrderer;
+ }
+
+ public void setDeletionBreaksOrdering(boolean flag) { deletionBreaksOrdering = flag; }
+
+ public boolean getDeletionBreaksOrdering() { return deletionBreaksOrdering; }
+
+ /** Called before hit lists or positions are used */
+ private void ensureSorted() {
+ if ( ! orderedHits && ! hitsSorted && likelyHitsHaveCorrectValueForSortFields()) {
+ sort();
+ }
+ }
+
+ /**
+ * Returns true if all the hits recursively contained in this
+ * is cached
+ */
+ public @Override boolean isCached() {
+ if (notCachedCount<1) return true;
+ if (subgroupCount<1) return false; // No need to check below
+
+ // Else check recursively
+ for (Hit hit : hits) {
+ if (hit instanceof HitGroup) {
+ if (hit.isCached()) return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns whether all hits in this result have been filled with
+ * the properties contained in the given summary class. Note that
+ * this method will also return true if no hits in this result are
+ * fillable.
+ */
+ public boolean isFilled(String summaryClass) {
+ Set<String> filled = getFilled();
+ return (filled == null || filled.contains(summaryClass));
+ }
+
+
+ /**
+ * Sets sorting information to be the same as for the provided hitGroup.
+ * The contained hits should already be sorted in the order specified by
+ * the hitGroup given as argument.
+ */
+ public void copyOrdering(HitGroup hitGroup) {
+ setOrderer(hitGroup.getOrderer());
+ setDeletionBreaksOrdering(hitGroup.getDeletionBreaksOrdering());
+ setOrdered(hitGroup.orderedHits);
+ }
+
+ // -------------- State bookkeeping
+
+ /** Ensures result invariants. Must be called when a hit is added to this result. */
+ private void handleNewHit(Hit hit) {
+ if (!hit.isAuxiliary())
+ concreteHitCount++;
+
+ if (hit.getAddNumber() < 0) {
+ hit.setAddNumber(size());
+ }
+
+ hitsSorted = false;
+ Set<String> hitFilled = hit.getFilled();
+
+ if (hitFilled != null) {
+ Set<String> filled = getFilledInternal();
+ if (filled == null) {
+ if (hitFilled.isEmpty()) {
+ filled = null;
+ } else if (hitFilled.size() == 1) {
+ filled = Collections.singleton(hitFilled.iterator().next());
+ } else {
+ filled = new HashSet<>(hitFilled);
+ }
+ setFilledInternal(filled);
+ } else {
+ if (filled.size() == 1) {
+ if ( ! hitFilled.contains(filled.iterator().next())) {
+ filled = null; // No intersection
+ setFilledInternal(filled);
+ }
+ } else {
+ filled.retainAll(hitFilled);
+ }
+ }
+ }
+
+ if (hit instanceof HitGroup) {
+ subgroupCount++;
+ }
+ if (!hit.isCached()) {
+ notCachedCount++;
+ }
+ }
+
+ // Filled is not kept in sync at removal
+ private void handleRemovedHit(Hit hit) {
+ if (!hit.isAuxiliary()) {
+ concreteHitCount--;
+ if (!hit.isCached())
+ notCachedCount--;
+ }
+ else if (hit instanceof HitGroup) {
+ subgroupCount--;
+ }
+
+ if (deletionBreaksOrdering) {
+ hitsSorted = false;
+ }
+ }
+
+ private void analyzeHit(Hit hit) {
+ if (hit instanceof HitGroup) {
+ ((HitGroup)hit).analyze();
+ }
+ if (!hit.isAuxiliary())
+ concreteHitCount++;
+
+ if (!hit.isCached())
+ notCachedCount++;
+ }
+
+ /**
+ * Update concreteHitCount, cached and filled by iterating trough the hits of this result.
+ * Recursively also update all subgroups.
+ */
+ public void analyze() {
+ concreteHitCount=0;
+ setFilledInternal(null);
+ notCachedCount=0;
+ Set<String> filled = getFilledInternal();
+
+ Iterator<Hit> i = unorderedIterator();
+ while (filled == null && i.hasNext()) {
+ Hit hit = i.next();
+ analyzeHit(hit);
+ Set<String> hitFilled = hit.getFilled();
+ if (hitFilled != null) {
+ filled = (hitFilled.size() == 1)
+ ? Collections.singleton(hitFilled.iterator().next())
+ : hitFilled.isEmpty() ? null : new HashSet<>(hitFilled);
+ setFilledInternal(filled);
+ }
+ }
+ String singleKey = null;
+ if (filled != null && filled.size() == 1) {
+ singleKey = filled.iterator().next();
+ }
+
+
+ for (; i.hasNext();) {
+ Hit hit = i.next();
+ analyzeHit(hit);
+
+ if (filled != null) {
+ Set<String> hitFilled = hit.getFilled();
+ if (hitFilled == null) {
+ // Intentionally empty. Strange semantic, null -> matches everything
+ } else if (hitFilled.isEmpty()) {
+ filled = null; // No intersection
+ setFilledInternal(filled);
+ } else {
+ if (filled.size() == 1) {
+ if ( ! hitFilled.contains(singleKey)) {
+ filled = null; // No intersection
+ setFilledInternal(filled);
+ singleKey = null;
+ }
+ } else {
+ filled.retainAll(hitFilled);
+ if (filled.size() == 1) {
+ singleKey = filled.iterator().next();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public HitGroup clone() {
+ HitGroup hitGroupClone = (HitGroup) super.clone();
+ hitGroupClone.hits = new ListenableArrayList<>(this.hits.size());
+ hitGroupClone.unmodifiableHits = Collections.unmodifiableList(hitGroupClone.hits);
+ for (Iterator<Hit> i = this.hits.iterator(); i.hasNext();) {
+ Hit hitClone = i.next().clone();
+ hitGroupClone.hits.add(hitClone);
+ }
+ if (this.errorHit!=null) { // Find the cloned error and assign it
+ for (Hit hit : hitGroupClone.asList()) {
+ if (hit instanceof ErrorHit)
+ hitGroupClone.errorHit=(ErrorHit)hit;
+ }
+ }
+
+ if (this.getFilledInternal()!=null) {
+ hitGroupClone.setFilledInternal(new HashSet<>(this.getFilledInternal()));
+ }
+
+ return hitGroupClone;
+ }
+
+ @Override
+ public void setFillable() {}
+
+ /** Ignored as this should always be derived from the content hits */
+ @Override
+ public void setFilled(String summaryClass) {}
+
+ @Override
+ public boolean isFillable() {
+ return fillableHits().iterator().hasNext();
+ }
+
+ @Override
+ public Set<String> getFilled() {
+ Iterator<Hit> hitIterator = hits.iterator();
+ Set<String> firstSummaryNames = getSummaryNamesNextFilledHit(hitIterator);
+ if (firstSummaryNames == null || firstSummaryNames.isEmpty())
+ return firstSummaryNames;
+
+ Set<String> intersection = firstSummaryNames;
+ while (true) {
+ Set<String> summaryNames = getSummaryNamesNextFilledHit(hitIterator);
+ if (summaryNames == null)
+ break;
+
+ if (intersection.size() == 1)
+ return getFilledSingle(first(intersection), hitIterator);
+
+
+ boolean notInSet = false;
+ if (intersection == firstSummaryNames) {
+ if (intersection.size() == summaryNames.size()) {
+ for(String s : summaryNames) {
+ if ( ! intersection.contains(s)) {
+ intersection = new HashSet<>(firstSummaryNames);
+ notInSet = true;
+ break;
+ }
+ }
+ }
+ }
+ if (notInSet) {
+ intersection.retainAll(summaryNames);
+ }
+
+ }
+
+ return intersection;
+ }
+
+ private Set<String> getSummaryNamesNextFilledHit(Iterator<Hit> hitIterator) {
+ while (hitIterator.hasNext()) {
+ Set<String> filled = hitIterator.next().getFilled();
+ if (filled != null)
+ return filled;
+ }
+ return null;
+ }
+
+ private Set<String> getFilledSingle(String summaryName, Iterator<Hit> hitIterator) {
+ while (true) {
+ Set<String> summaryNames = getSummaryNamesNextFilledHit(hitIterator);
+ if (summaryNames == null) {
+ return Collections.singleton(summaryName);
+ } else if (!summaryNames.contains(summaryName)) {
+ return Collections.emptySet();
+ }
+ }
+ }
+
+ private Iterable<Hit> fillableHits() {
+ Predicate<Hit> isFillable = hit -> hit.isFillable();
+
+ return Iterables.filter(hits, isFillable);
+ }
+
+ /** Returns the incoming hit buffer to which new hits can be added to this asynchronous, if supported by the instance */
+ @Override
+ public IncomingData<Hit> incoming() { return incomingHits; }
+
+ @Override
+ public ListenableFuture<DataList<Hit>> complete() { return completedFuture; }
+
+ @Override
+ public void addDataListener(Runnable runnable) {
+ hits.addListener(runnable);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/HitGroupsLastComparator.java b/container-search/src/main/java/com/yahoo/search/result/HitGroupsLastComparator.java
new file mode 100644
index 00000000000..0fe73a5afb5
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/HitGroupsLastComparator.java
@@ -0,0 +1,34 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Comparator;
+
+/**
+ * Ensures that HitGroups are placed last in the result.
+ *
+ * @author tonytv
+ */
+public class HitGroupsLastComparator extends ChainableComparator {
+
+ public HitGroupsLastComparator(Comparator<Hit> secondaryComparator) {
+ super(secondaryComparator);
+ }
+
+ @Override
+ public int compare(Hit left, Hit right) {
+ if (isHitGroup(left) ^ isHitGroup(right)) {
+ return isHitGroup(left) ? 1 : -1;
+ } else {
+ return super.compare(left, right);
+ }
+ }
+
+ private boolean isHitGroup(Hit hit) {
+ return hit instanceof HitGroup;
+ }
+
+ @Override
+ public String toString() {
+ return getSecondaryComparator().toString();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/HitIterator.java b/container-search/src/main/java/com/yahoo/search/result/HitIterator.java
new file mode 100644
index 00000000000..adf642a28ec
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/HitIterator.java
@@ -0,0 +1,66 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import com.yahoo.search.Result;
+
+
+/**
+ * An iterator for the list of hits in a result. This iterator supports the remove operation.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class HitIterator implements Iterator<Hit> {
+
+ /** The index into the list of hits */
+ private int index = -1;
+
+ /** The list of hits to iterate over */
+ private List<Hit> hits = null;
+
+ /** The result the hits belong to */
+ private HitGroup hitGroup = null;
+
+ /** Whether the iterator is in a state where remove is OK */
+ private boolean canRemove = false;
+
+ public HitIterator(HitGroup hitGroup, List<Hit> hits) {
+ this.hitGroup = hitGroup;
+ this.hits = hits;
+ }
+
+ public HitIterator(Result result, List<Hit> hits) {
+ this.hitGroup = result.hits();
+ this.hits = hits;
+ }
+
+ public boolean hasNext() {
+ if (hits.size() > (index + 1)) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public Hit next() throws NoSuchElementException {
+ if (hits.size() <= (index + 1)) {
+ throw new NoSuchElementException();
+ } else {
+ canRemove = true;
+ return hits.get(++index);
+ }
+ }
+
+ public void remove() throws IllegalStateException {
+ if (!canRemove) {
+ throw new IllegalStateException();
+ }
+ hitGroup.remove(index);
+ index--;
+ canRemove = false;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/HitOrderer.java b/container-search/src/main/java/com/yahoo/search/result/HitOrderer.java
new file mode 100644
index 00000000000..5982a93d86a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/HitOrderer.java
@@ -0,0 +1,30 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * A class capable of ordering a list of hits
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon S Bratseth</a>
+ */
+
+public abstract class HitOrderer {
+
+ /** Orders the given list of hits */
+ public abstract void order(List<Hit> hits);
+
+ /**
+ * Returns the Comparator that this HitOrderer uses internally to
+ * sort hits. Returns null if no Comparator is used.
+ * <p>
+ * This default implementation returns null.
+ *
+ * @return the Comparator used to order hits, or null
+ */
+ public Comparator<Hit> getComparator() {
+ return null;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/HitSortOrderer.java b/container-search/src/main/java/com/yahoo/search/result/HitSortOrderer.java
new file mode 100644
index 00000000000..c532aba99d8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/HitSortOrderer.java
@@ -0,0 +1,50 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.search.query.Sorting;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * A hit orderer which can be assigned to a HitGroup to keep that group's
+ * hit sorted in accordance with the sorting specification given when this is created.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class HitSortOrderer extends HitOrderer {
+
+ private final Comparator<Hit> fieldComparator;
+
+ /** Create a sort order from a sorting */
+ public HitSortOrderer(Sorting sorting) {
+ fieldComparator =
+ new MetaHitsFirstComparator(
+ new HitGroupsLastComparator(
+ new FieldComparator(sorting)));
+ }
+
+ /**
+ * Create a sort order from a comparator.
+ * This will be appended to the standard comparators used by this.
+ */
+ public HitSortOrderer(Comparator<Hit> comparator) {
+ fieldComparator = new MetaHitsFirstComparator(new HitGroupsLastComparator(comparator));
+ }
+
+ /**
+ * Orders the given list of hits according to the sorting given at construction
+ *
+ * Meta hits are sorted before concrete hits, but have no internal
+ * ordering. The sorting is stable.
+ */
+ public void order(List<Hit> hits) {
+ Collections.sort(hits, fieldComparator);
+ }
+
+ public Comparator<Hit> getComparator() {
+ return fieldComparator;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/MetaHitsFirstComparator.java b/container-search/src/main/java/com/yahoo/search/result/MetaHitsFirstComparator.java
new file mode 100644
index 00000000000..900f47da6e4
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/MetaHitsFirstComparator.java
@@ -0,0 +1,35 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Comparator;
+
+/**
+ * Ensures that meta hits are sorted before normal hits. All meta hits are
+ * considered equal.
+ *
+ * @author tonytv
+ */
+public class MetaHitsFirstComparator extends ChainableComparator {
+
+ public MetaHitsFirstComparator(Comparator<Hit> secondaryComparator) {
+ super(secondaryComparator);
+ }
+
+ @Override
+ public int compare(Hit left, Hit right) {
+ if (left.isMeta() && right.isMeta()) {
+ return 0;
+ } else if (left.isMeta()) {
+ return -1;
+ } else if (right.isMeta()) {
+ return 1;
+ } else {
+ return super.compare(left, right);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return getSecondaryComparator().toString();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/NanNumber.java b/container-search/src/main/java/com/yahoo/search/result/NanNumber.java
new file mode 100644
index 00000000000..385be70cd4c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/NanNumber.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+/**
+ * A class representing unset or undefined numeric values.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@SuppressWarnings("serial")
+public final class NanNumber extends Number {
+ public static final NanNumber NaN = new NanNumber();
+
+ private NanNumber() {
+ }
+
+ @Override
+ public double doubleValue() {
+ return Double.NaN;
+ }
+
+ @Override
+ public float floatValue() {
+ return Float.NaN;
+ }
+
+ @Override
+ public int intValue() {
+ return 0;
+ }
+
+ @Override
+ public long longValue() {
+ return 0L;
+ }
+
+ @Override
+ public String toString() {
+ return "";
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/Relevance.java b/container-search/src/main/java/com/yahoo/search/result/Relevance.java
new file mode 100644
index 00000000000..df79b64585e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/Relevance.java
@@ -0,0 +1,86 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.text.DoubleFormatter;
+
+/**
+ * A relevance double value. These values should always be normalized between 0 and 1 (where 1 means perfect),
+ * however, this is not enforced.
+ * <p>
+ * Sources may create subclasses of this to include additional information or functionality.
+ *
+ * @author bratseth
+ */
+public class Relevance implements Comparable<Relevance> {
+
+ private static final long serialVersionUID = 4536685722731661704L;
+
+ /** The relevancy score. */
+ private double score;
+
+ /**
+ * Construct a relevancy object with an initial value.
+ * This initial value should ideally be a normalized value
+ * between 0 and 1, but that is not enforced.
+ *
+ * @param score the inital value (rank score)
+ */
+ public Relevance(double score) {
+ this.score=score;
+ }
+
+ /**
+ * Set score value to this value. This should ideally be a
+ * normalized value between 0 and 1, but that is not enforced.
+ * NaN is also a legal value, for elements where it makes no sense to assign a particular value.
+ */
+ public void setScore(double score) { this.score = score; }
+
+ /**
+ * Returns the relevancy score of this, preferably a normalized value
+ * between 0 and 1 but this is not guaranteed by this framework
+ */
+ public double getScore() { return score; }
+
+ /**
+ * Returns the score value as a string
+ */
+ public @Override String toString() {
+ return DoubleFormatter.stringValue(score);
+ }
+
+ /** Compares relevancy values with */
+ public int compareTo(Relevance other) {
+ double thisScore = getScore();
+ double otherScore = other.getScore();
+ if (Double.isNaN(thisScore)) {
+ if (Double.isNaN(otherScore)) {
+ return 0;
+ } else {
+ return -1;
+ }
+ } else if (Double.isNaN(otherScore)) {
+ return 1;
+ } else {
+ return Double.compare(thisScore, otherScore);
+ }
+ }
+
+ /** Compares relevancy values */
+ public @Override boolean equals(Object object) {
+ if (object==this) return true;
+
+ if (!(object instanceof Relevance)) { return false; }
+
+ Relevance other = (Relevance) object;
+ return getScore() == other.getScore();
+ }
+
+ /** Returns a hash from the relevancy value */
+ public @Override int hashCode() {
+ double hash=getScore()*335451367; // A largish prime
+ if (hash>-1 && hash<1) hash=1/hash;
+ return (int) hash;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/StructuredData.java b/container-search/src/main/java/com/yahoo/search/result/StructuredData.java
new file mode 100644
index 00000000000..c49f8a04b97
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/StructuredData.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import com.yahoo.data.access.Inspector;
+import com.yahoo.data.access.Inspectable;
+import com.yahoo.data.access.simple.JsonRender;
+import com.yahoo.data.JsonProducer;
+import com.yahoo.data.XmlProducer;
+import com.yahoo.prelude.hitfield.XmlRenderer;
+
+/**
+ * A wrapper for structured data representing feature values.
+ */
+public class StructuredData implements Inspectable, JsonProducer, XmlProducer {
+
+ private final Inspector value;
+
+ public StructuredData(Inspector value) {
+ this.value = value;
+ }
+
+ @Override
+ public Inspector inspect() {
+ return value;
+ }
+
+ public String toString() {
+ return toXML();
+ }
+
+ @Override
+ public String toXML() {
+ return writeXML(new StringBuilder()).toString();
+ }
+
+ @Override
+ public StringBuilder writeXML(StringBuilder target) {
+ return XmlRenderer.render(target, value);
+ }
+
+ @Override
+ public String toJson() {
+ return writeJson(new StringBuilder()).toString();
+ }
+
+ @Override
+ public StringBuilder writeJson(StringBuilder target) {
+ return JsonRender.render(value, target, true);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/Templating.java b/container-search/src/main/java/com/yahoo/search/result/Templating.java
new file mode 100644
index 00000000000..61dd38aaf93
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/Templating.java
@@ -0,0 +1,210 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.result;
+
+import java.util.Map;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.prelude.templates.SearchRendererAdaptor;
+import com.yahoo.prelude.templates.TemplateSet;
+import com.yahoo.prelude.templates.UserTemplate;
+import com.yahoo.processing.rendering.Renderer;
+import com.yahoo.search.Result;
+import com.yahoo.search.query.Presentation;
+
+/**
+ * Helper methods and data store for result attributes geared towards result
+ * rendering and presentation.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class Templating {
+
+ private final Result result;
+ private Renderer<Result> renderer;
+
+ public Templating(Result result) {
+ super();
+ this.result = result;
+ }
+
+ /**
+ * Returns The first hit presented in the result as an index into the global
+ * list of all hits generated by the user query.
+ */
+ public int getFirstHitNo() {
+ return result.getQuery().getOffset() + 1;
+ }
+
+ /**
+ * Returns the first hit of the next result page, 0 if there aren't any more
+ * hits available
+ */
+ public long getNextFirstHitNo() {
+ if (result.getQuery().getHits() > result.getConcreteHitCount()) {
+ return 0;
+ }
+
+ return Math.min(getLastHitNo() + 1, result.getTotalHitCount());
+ }
+
+ /**
+ * Returns the first hit of the next result page, 0 if there aren't any more
+ * hits available
+ */
+ public long getNextLastHitNo() {
+ if (result.getQuery().getHits() > result.getConcreteHitCount()) {
+ return 0;
+ }
+
+ return Math.min(getLastHitNo() + result.getConcreteHitCount(), result.getTotalHitCount());
+ }
+
+ /**
+ * Returns the number of the last result of the current hit page.
+ */
+ public int getLastHitNo() {
+ return getFirstHitNo() + result.getConcreteHitCount() - 1;
+ }
+
+ /**
+ * The first hit presented on the previous result page as an index into the
+ * global list of all hits generated by the user query
+ */
+ public int getPrevFirstHitNo() {
+ return Math.max(getFirstHitNo() - result.getQuery().getHits(), 1);
+ }
+
+ /**
+ * The last hit presented on the previous result page as an index into the
+ * global list of all hits generated by the user query
+ */
+ public int getPrevLastHitNo() {
+ return Math.max(getFirstHitNo() - 1, 0);
+ }
+
+ /**
+ * An URL that may be used to obtain the next result page.
+ */
+ public String getNextResultURL() {
+ HttpRequest request = result.getQuery().getHttpRequest();
+ StringBuilder nextURL = new StringBuilder();
+
+ nextURL.append(getPath(request)).append("?");
+ parametersExceptOffset(request, nextURL);
+
+ int offset = getLastHitNo();
+
+ nextURL.append("&").append("offset=").append(Integer.toString(offset));
+ return nextURL.toString();
+ }
+
+ /**
+ * An URL that may be used to obtain the previous result page.
+ */
+ public String getPreviousResultURL() {
+ HttpRequest request = result.getQuery().getHttpRequest();
+ StringBuilder prevURL = new StringBuilder();
+
+ prevURL.append(getPath(request)).append("?");
+ parametersExceptOffset(request, prevURL);
+ int offset = getPrevFirstHitNo() - 1;
+ prevURL.append("&").append("offset=").append(Integer.toString(offset));
+ return prevURL.toString();
+ }
+
+ public String getCurrentResultURL() {
+ HttpRequest request = result.getQuery().getHttpRequest();
+ StringBuilder thisURL = new StringBuilder();
+
+ thisURL.append(getPath(request)).append("?");
+ parameters(request, thisURL);
+ return thisURL.toString();
+ }
+
+ private String getPath(HttpRequest request) {
+ String path = request.getUri().getPath();
+ if (path == null) {
+ path = "";
+ }
+ return path;
+ }
+
+ private void parametersExceptOffset(HttpRequest request, StringBuilder nextURL) {
+ int startLength = nextURL.length();
+ for (Map.Entry<String, String> property : request.propertyMap().entrySet()) {
+ if (property.getKey().equals("offset")) continue;
+
+ if (nextURL.length() > startLength)
+ nextURL.append("&");
+ nextURL.append(property.getKey()).append("=").append(property.getValue());
+ }
+ }
+
+ private void parameters(HttpRequest request, StringBuilder nextURL) {
+ int startLength = nextURL.length();
+ for (Map.Entry<String, String> property : request.propertyMap().entrySet()) {
+ if (nextURL.length() > startLength)
+ nextURL.append("&");
+ nextURL.append(property.getKey()).append("=").append(property.getValue());
+ }
+ }
+
+ /**
+ * Returns the templates which will render the result. This is never null.
+ * If default rendering is used, it is a TemplateSet containing no
+ * templates.
+ */
+ @SuppressWarnings("rawtypes")
+ public UserTemplate getTemplates() {
+ if (renderer == null) {
+ return TemplateSet.getDefault();
+ } else if (renderer instanceof SearchRendererAdaptor) {
+ return ((SearchRendererAdaptor) renderer).getAdaptee();
+ } else {
+ throw new RuntimeException(
+ "Please use getTemplate() instead of getTemplates() when using the new template api.");
+ }
+ }
+
+ /**
+ * Sets the template set which should render this result set
+ *
+ * @param templates
+ * the templates which should render this result, or null to
+ * use the default xml rendering
+ */
+ @SuppressWarnings("deprecation")
+ public void setTemplates(@SuppressWarnings("rawtypes") UserTemplate templates) {
+ if (templates == null) {
+ setTemplates(TemplateSet.getDefault());
+ } else {
+ setRenderer(new SearchRendererAdaptor(templates));
+ }
+ }
+
+ /**
+ * @deprecated since 5.1.21, use {@link Presentation#getRenderer()}
+ */
+ @Deprecated // OK Do not remove on Vespa 6. Remove when we move everything having to do with templates
+ public Renderer<Result> getRenderer() {
+ return renderer;
+ }
+
+ /**
+ * @deprecated since 5.1.21, use {@link Presentation#setRenderer(com.yahoo.component.ComponentSpecification)}
+ */
+ @Deprecated // OK Do not remove on Vespa 6. Remove when we move everything having to do with templates
+ public void setRenderer(Renderer<Result> renderer) {
+ this.renderer = renderer;
+ }
+
+ /**
+ * For internal use only.
+ */
+ public boolean usesDefaultTemplate() {
+ return renderer == null ||
+ (renderer instanceof SearchRendererAdaptor &&
+ ((SearchRendererAdaptor) renderer).getAdaptee().isDefaultTemplateSet());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/result/package-info.java b/container-search/src/main/java/com/yahoo/search/result/package-info.java
new file mode 100644
index 00000000000..aa93d0fdeab
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/result/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * The content of a Result produced in response to a Query.
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.result;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/AsyncExecution.java b/container-search/src/main/java/com/yahoo/search/searchchain/AsyncExecution.java
new file mode 100644
index 00000000000..e1794a73a93
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/AsyncExecution.java
@@ -0,0 +1,204 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+import com.yahoo.component.chain.Chain;
+import com.yahoo.concurrent.ThreadFactoryFactory;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.*;
+
+/**
+ * Provides asynchronous execution of searchchains.
+ *
+ * <p>
+ * AsyncExecution is implemented as an asynchronous wrapper around Execution
+ * that returns Future.
+ * </p>
+ *
+ * This is used in the following way
+ *
+ * <pre>
+ * Execution execution = new Execution(searchChain, context);
+ * AsyncExecution asyncExecution = new AsyncExecution(execution);
+ * Future&lt;Result&gt; future = asyncExecution.search(query)
+ * try {
+ * result = future.get(timeout, TimeUnit.milliseconds);
+ * } catch(TimeoutException e) {
+ * // Handle timeout
+ * }
+ * </pre>
+ *
+ * <p>
+ * Note that the query is not a thread safe object and cannot be shared between
+ * multiple concurrent executions - a clone() must be made, or a new query
+ * created for each AsyncExecution instance.
+ * </p>
+ *
+ * @see com.yahoo.search.searchchain.Execution
+ * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a>
+ */
+public class AsyncExecution {
+
+ private static final ThreadFactory threadFactory = ThreadFactoryFactory.getThreadFactory("search");
+
+ private static final Executor executorMain = createExecutor();
+
+ private static Executor createExecutor() {
+ ThreadPoolExecutor executor = new ThreadPoolExecutor(100, Integer.MAX_VALUE, 1L, TimeUnit.SECONDS,
+ new SynchronousQueue<>(false), threadFactory);
+ // Prestart needed, if not all threads will be created by the fist N tasks and hence they might also
+ // get the dreaded thread locals initialized even if they will never run.
+ // That counters what we we want to achieve with the Q that will prefer thread locality.
+ executor.prestartAllCoreThreads();
+ return executor;
+ }
+
+ /** The execution this executes */
+ private final Execution execution;
+
+ /**
+ * Creates an async execution.
+ *
+ * @param chain the chain to execute
+ * @param execution the execution holding the context of this
+ */
+ public AsyncExecution(Chain<? extends Searcher> chain, Execution execution) {
+ this(execution.context(), chain);
+ }
+
+ /**
+ * Creates an async execution.
+ *
+ * @param chain the chain to execute
+ * @param context the the context of this
+ */
+ public AsyncExecution(Chain<? extends Searcher> chain, Execution.Context context) {
+ this(context, chain);
+ }
+
+ /**
+ * <p>
+ * Creates an async execution from an existing execution. This async
+ * execution will execute the chain from the given execution, <i>starting
+ * from the next searcher in that chain.</i> This is handy to execute
+ * multiple queries to the rest of the chain in parallel. If the Execution
+ * is freshly instantiated, the search will obviously start from the first
+ * searcher.
+ * </p>
+ *
+ * <p>
+ * The state of the given execution is read on construction of this and not
+ * used later - the argument execution can be reused for other purposes.
+ * </p>
+ *
+ * @param execution the execution from which the state of this is created
+ *
+ * @see Execution#Execution(Chain, com.yahoo.search.searchchain.Execution.Context)
+ * @see #AsyncExecution(Chain, Execution)
+ */
+ public AsyncExecution(Execution execution) {
+ this.execution = new Execution(execution);
+ }
+
+ private AsyncExecution(Execution.Context context, Chain<? extends Searcher> chain) {
+ this.execution = new Execution(chain, context);
+ }
+
+ /**
+ * Does an async search, note that the query argument cannot simultaneously
+ * be used to execute any other searches, a clone() must be made of the
+ * query for each async execution if the same query is to be used in more
+ * than one.
+ *
+ * @see com.yahoo.search.searchchain.Execution
+ */
+ public FutureResult search(final Query query) {
+ return getFutureResult(() -> execution.search(query), query);
+ }
+
+ public FutureResult searchAndFill(final Query query) {
+ return getFutureResult(() -> {
+ Result result = execution.search(query);
+ execution.fill(result, query.getPresentation().getSummary());
+ return result;
+ }, query);
+ }
+
+ private static Executor getExecutor() {
+ return executorMain;
+ }
+
+ /**
+ * The future of this functions returns the original Result
+ *
+ * @see com.yahoo.search.searchchain.Execution
+ */
+ public FutureResult fill(final Result result, final String summaryClass) {
+ return getFutureResult(() -> {
+ execution.fill(result, summaryClass);
+ return result;
+ }, result.getQuery());
+
+ }
+
+ private static <T> Future<T> getFuture(Callable<T> callable) {
+ final FutureTask<T> future = new FutureTask<>(callable);
+ getExecutor().execute(future);
+ return future;
+ }
+
+ private static Future<Void> runTask(Runnable runnable) {
+ return getFuture(() -> {
+ runnable.run();
+ return null;
+ });
+ }
+
+ private FutureResult getFutureResult(Callable<Result> callable, Query query) {
+ FutureResult future = new FutureResult(callable, execution, query);
+ getExecutor().execute(future);
+ return future;
+ }
+
+ /*
+ * Waits for all futures until the given timeout. If a FutureResult isn't
+ * done when the timeout expires, it will be cancelled, and it will return a
+ * result. All unfinished Futures will be cancelled.
+ *
+ * @return the list of results in the same order as returned from the task
+ * collection
+ */
+ public static List<Result> waitForAll(Collection<FutureResult> tasks, long timeoutMs) {
+
+ // Copy the list in case it is modified while we are waiting
+ final List<FutureResult> workingTasks = new ArrayList<>(tasks);
+ try {
+ runTask(() -> {
+ for (FutureResult task : workingTasks)
+ task.get();
+ }).get(timeoutMs, TimeUnit.MILLISECONDS);
+ }catch (TimeoutException | InterruptedException | ExecutionException e) {
+ // Handle timeouts below
+ }
+
+ final List<Result> results = new ArrayList<>(tasks.size());
+ for (FutureResult atask : workingTasks) {
+ Result result;
+ if (atask.isDone() && !atask.isCancelled()) {
+ result = atask.get(); // Since isDone() = true, this won't
+ // block.
+ } else { // Not done and no errors thrown
+ result = new Result(atask.getQuery(),
+ atask.createTimeoutError());
+ }
+ results.add(result);
+ }
+ return results;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/Execution.java b/container-search/src/main/java/com/yahoo/search/searchchain/Execution.java
new file mode 100644
index 00000000000..a888ad9b59e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/Execution.java
@@ -0,0 +1,672 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+import com.yahoo.component.chain.Chain;
+import com.yahoo.language.Linguistics;
+import com.yahoo.log.LogLevel;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.Ping;
+import com.yahoo.prelude.Pong;
+import com.yahoo.prelude.query.parser.SpecialTokenRegistry;
+import com.yahoo.processing.Processor;
+import com.yahoo.processing.Request;
+import com.yahoo.processing.Response;
+import com.yahoo.protect.Validator;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.cluster.PingableSearcher;
+import com.yahoo.search.rendering.RendererRegistry;
+import com.yahoo.search.statistics.TimeTracker;
+
+import java.util.logging.Logger;
+
+/**
+ * <p>An execution of a search chain. This keeps track of the call state for an execution (in the calling thread)
+ * of the searchers of a search chain.</p>
+ *
+ * <p>To execute a search chain, simply do
+ * <pre>
+ * Result result = new Execution(mySearchChain, execution.context()).search(query)
+ * </pre>
+ *
+ *
+ * <p>See also {@link AsyncExecution}, which performs an execution in a separate thread than the caller.</p>
+ *
+ * <p>Execution instances should not be reused for multiple separate executions.</p>
+ *
+ * @author bratseth
+ */
+public class Execution extends com.yahoo.processing.execution.Execution {
+
+ public static final String ATTRIBUTEPREFETCH = "attributeprefetch";
+
+ /**
+ * The execution context is the search chain's current view of the indexes,
+ * search chain registrys, etc. Searcher instances may set values here to
+ * change the behavior of the rest of the search chain.
+ * <p>
+ * The Context class simply carries a set of objects which define the
+ * environment for the search. <b>Important:</b> All objects available through context need to
+ * be either truly immutable or support the freeze pattern.
+ * <p>
+ * If you are implementing a searcher where you need to create a new Context
+ * instance to create an Execution, you should use the context from the
+ * execution the searcher was invoked from. You can also copy
+ * (Context.shallowCopy()) the incoming context if it is necessary to do
+ * more. In other words, a minimal example would be:<br>
+ * new Execution(searchChain, execution.context())
+ */
+ public static final class Context {
+
+ /**
+ * Whether the search should perform detailed diagnostics.
+ */
+ private boolean detailedDiagnostics = false;
+
+ /**
+ * Whether the container was considered to be in a breakdown state when
+ * this query started.
+ */
+ private boolean breakdown = false;
+
+ /**
+ * The search chain registry current when this execution was created, or
+ * when the registry was first accessed, or null if it was not set on
+ * creation or has been accessed yet. No setter method is intentional.
+ */
+ private SearchChainRegistry searchChainRegistry = null;
+
+ private IndexFacts indexFacts = null;
+
+ /**
+ * The current set of special tokens.
+ */
+ private SpecialTokenRegistry tokenRegistry = null;
+
+ /**
+ * The current template registry.
+ */
+ private RendererRegistry rendererRegistry = null;
+
+ /**
+ * The current linguistics.
+ */
+ private Linguistics linguistics = null;
+
+ /** Always set if this context belongs to an execution, never set if it does not. */
+ private final Execution owner;
+
+ // Please don't add more constructors to the public interface of Context
+ // unless the constructor is reasonably safe for an inexperienced user
+ // in a production setting. Since queries blow up in a spectacular
+ // fashion if Context is in a bad state, the Context() constructor is
+ // package private.
+
+ /** Create a context used to carry state into another context */
+ Context() { this.owner=null; }
+
+ /** Create a context which belongs to an execution */
+ Context(Execution owner) { this.owner=owner; }
+
+ /**
+ * Creates a context from arguments, all of which may be null, though
+ * this can be risky. If you are doing this outside a test, it is
+ * usually better to do something like execution.context().shallowCopy()
+ * instead, and then set the fields you need to change. It is also safe
+ * to use the context from the incoming execution directly. In other
+ * words, a plug-in writer should practically never construct a Context
+ * instance directly.
+ * <p>
+ * This context is never attached to an execution but is used to carry state into
+ * another context.
+ */
+ public Context(SearchChainRegistry searchChainRegistry, IndexFacts indexFacts,
+ SpecialTokenRegistry tokenRegistry, RendererRegistry rendererRegistry, Linguistics linguistics)
+ {
+ owner=null;
+ // The next time something is added here, compose into wrapper objects. Many arguments...
+
+ // Four methods need to be updated when adding something:
+ // fill(Context), populateFrom(Context), equals(Context) and,
+ // obviously, the most complete constructor.
+ this.searchChainRegistry = searchChainRegistry;
+ this.indexFacts = indexFacts;
+ this.tokenRegistry = tokenRegistry;
+ this.rendererRegistry = rendererRegistry;
+ this.linguistics = linguistics;
+ }
+
+ /** Creates a context stub with no information. This is for unit testing. */
+ public static Context createContextStub() {
+ return new Context(null, null, null, null, null);
+ }
+
+ /**
+ * Create a Context instance where only the index related settings are
+ * initialized. This is for unit testing.
+ */
+ public static Context createContextStub(IndexFacts indexFacts) {
+ return new Context(null, indexFacts, null, null, null);
+ }
+
+ /**
+ * Create a Context instance where only the search chain registry and index facts are
+ * initialized. This is for unit testing.
+ */
+ public static Context createContextStub(SearchChainRegistry searchChainRegistry, IndexFacts indexFacts) {
+ return new Context(searchChainRegistry, indexFacts, null, null, null);
+ }
+
+ /**
+ * Create a Context instance where only the search chain registry, index facts and linguistics are
+ * initialized. This is for unit testing.
+ */
+ public static Context createContextStub(SearchChainRegistry searchChainRegistry, IndexFacts indexFacts, Linguistics linguistics) {
+ return new Context(searchChainRegistry, indexFacts, null, null, linguistics);
+ }
+
+ /**
+ * Populate missing values in this from the given context.
+ * Values which are non-null in this will not be overwritten.
+ *
+ * @param sourceContext the context from which to get the parameters
+ */
+ public void populateFrom(Context sourceContext) {
+ // breakdown and detailedDiagnostics has no unset state, so they are always copied
+ detailedDiagnostics = sourceContext.detailedDiagnostics;
+ breakdown = sourceContext.breakdown;
+ if (indexFacts == null) {
+ indexFacts = sourceContext.indexFacts;
+ }
+ if (tokenRegistry == null) {
+ tokenRegistry = sourceContext.tokenRegistry;
+ }
+ if (searchChainRegistry == null) {
+ searchChainRegistry = sourceContext.searchChainRegistry;
+ }
+ if (rendererRegistry == null) {
+ rendererRegistry = sourceContext.rendererRegistry;
+ }
+ if (linguistics == null) {
+ linguistics = sourceContext.linguistics;
+ }
+ }
+
+ /**
+ * The brutal version of populateFrom().
+ *
+ * @param other a Context instance this will copy all state from
+ */
+ void fill(Context other) {
+ searchChainRegistry = other.searchChainRegistry;
+ indexFacts = other.indexFacts;
+ tokenRegistry = other.tokenRegistry;
+ rendererRegistry = other.rendererRegistry;
+ detailedDiagnostics = other.detailedDiagnostics;
+ breakdown = other.breakdown;
+ linguistics = other.linguistics;
+ }
+
+ public boolean equals(Context other) {
+ // equals() needs to be cheap, that's yet another reason we can only
+ // allow immutables and frozen objects in the context
+ return other.indexFacts == indexFacts
+ && other.rendererRegistry == rendererRegistry
+ && other.tokenRegistry == tokenRegistry
+ && other.searchChainRegistry == searchChainRegistry
+ && other.detailedDiagnostics == detailedDiagnostics
+ && other.breakdown == breakdown
+ && other.linguistics == linguistics;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == null) {
+ return false;
+ }
+ if (other.getClass() != Context.class) {
+ return false;
+ } else {
+ return equals((Context) other);
+ }
+ }
+
+ /**
+ * Standard shallow copy, the new instance will carry the same
+ * references as this.
+ *
+ * @return a new instance which is a shallow copy
+ */
+ public Context shallowCopy() {
+ Context c = new Context();
+ c.fill(this);
+ return c;
+ }
+
+ /**
+ * This is used when building the Context stack. If Context has been
+ * changed since last time, build a new object. Otherwise simply return
+ * the previous snapshot.
+ *
+ * @param previous another Context instance to compare with
+ * @return a copy of this, or previous
+ */
+ Context copyIfChanged(Context previous) {
+ if (equals(previous)) {
+ return previous;
+ } else {
+ return shallowCopy();
+ }
+ }
+
+ /**
+ * Returns information about the indexes specified by the search definitions
+ * used in this system, or null if not know.
+ */
+ // TODO: Make a null default instance
+ public IndexFacts getIndexFacts() {
+ return indexFacts;
+ }
+
+ /**
+ * Use this to override index settings for the searchers below
+ * a given searcher, the easiest way to do this is to wrap the incoming
+ * IndexFacts instance in a subclass. E.g.
+ * execution.context().setIndexFacts(new WrapperClass(execution.context().getIndexFacts())).
+ *
+ * @param indexFacts
+ * an instance to override the following searcher's view of
+ * the indexes.
+ */
+ public void setIndexFacts(IndexFacts indexFacts) {
+ this.indexFacts = indexFacts;
+ }
+
+ /**
+ * Returns the search chain registry to use with this execution. This is
+ * a snapshot taken at creation of this execution, use
+ * Context.shallowCopy() to get a correctly instantiated Context if
+ * making a custom Context instance.
+ */
+ public SearchChainRegistry searchChainRegistry() {
+ return searchChainRegistry;
+ }
+
+ /**
+ * Returns the template registry to use with this execution. This is
+ * a snapshot taken at creation of this execution.
+ */
+ public RendererRegistry rendererRegistry() {
+ return rendererRegistry;
+ }
+
+ /**
+ * @return the current set of special strings for the query tokenizer
+ */
+ public SpecialTokenRegistry getTokenRegistry() {
+ return tokenRegistry;
+ }
+
+ /**
+ * Wrapping the incoming special token registry and then setting the
+ * wrapper as the token registry, can be used for changing the set of
+ * special tokens used by succeeding searchers. E.g.
+ * execution.context().setTokenRegistry(new WrapperClass(execution.context().getTokenRegistry())).
+ *
+ * @param tokenRegistry a new registry for overriding behavior of following searchers
+ */
+ public void setTokenRegistry(SpecialTokenRegistry tokenRegistry) {
+ this.tokenRegistry = tokenRegistry;
+ }
+
+ public void setDetailedDiagnostics(boolean breakdown) {
+ this.detailedDiagnostics = breakdown;
+ }
+
+ /**
+ * The container has some internal diagnostics mechanisms which may be
+ * costly, and therefore not active by default. Any general diagnostic
+ * mechanism which should not be active be default, may inspect that
+ * state here. If breakdown is assumed, a certain percentage of queries
+ * will have this set automatically.
+ *
+ * @return whether components exposing different level of diagnostics
+ * should go for the most detailed level
+ */
+ public boolean getDetailedDiagnostics() {
+ return detailedDiagnostics;
+ }
+
+ /**
+ * If too many queries time out, the search handler will assume the
+ * system is in a breakdown state. This state is propagated here.
+ *
+ * @return whether the system is assumed to be in a breakdown state
+ */
+ public boolean getBreakdown() {
+ return breakdown;
+ }
+
+ public void setBreakdown(boolean breakdown) {
+ this.breakdown = breakdown;
+ }
+
+ /**
+ * Returns the {@link Linguistics} object assigned to this Context. This object provides access to all the
+ * linguistic-related APIs, and comes pre-configured with the Execution given.
+ *
+ * @return The current Linguistics.
+ */
+ public Linguistics getLinguistics() {
+ return linguistics;
+ }
+
+ public void setLinguistics(Linguistics linguistics) {
+ this.linguistics = linguistics;
+ }
+
+ /** Creates a child trace if this has an owner, or a root trace otherwise */
+ private Trace createChildTrace() {
+ return owner!=null ? owner.trace().createChild() : Trace.createRoot(0);
+ }
+
+ /** Creates a child environment if this has an owner, or a root environment otherwise */
+ private Environment createChildEnvironment() {
+ return owner!=null ? owner.environment().nested() : Execution.Environment.<Searcher>createEmpty();
+ }
+
+ }
+
+ /**
+ * The index of where in the chain this Execution has its initial entry point.
+ * This is needed because executions can be started from the middle of other executions.
+ */
+ private final int entryIndex;
+
+ /** Time spent in each state of filling, searching or pinging. */
+ private final TimeTracker timer;
+
+ /** A searcher's view of state external to the search chain. */
+ // Note that the context plays the same role as the Environment of the super.Execution
+ // (although complicated by the need for stack-like behavior on changes).
+ // We might want to unify those at some point.
+ private final Context context = new Context(this);
+
+ /**
+ * Array for hiding context changes done in search by searcher following
+ * another.
+ */
+ private final Context[] contextCache;
+
+ private static final Logger log = Logger.getLogger(Execution.class.getName());
+
+ /**
+ * <p>
+ * Creates an execution from another. This execution will start at the
+ * <b>current next searcher</b> in the given execution, rather than at the
+ * start.
+ * </p>
+ *
+ * <p>
+ * The relevant state of the given execution is copied before this method
+ * returns - the argument execution can then be reused for any other
+ * purpose.
+ * </p>
+ */
+ public Execution(Execution execution) {
+ this(execution.chain(), execution.context, execution.nextIndex());
+ }
+
+ /** Creates an which executes nothing */
+ public Execution(Context context) {
+ this(new Chain<>(), context);
+ }
+
+ /**
+ * The usually best way of creating a new execution for a search chain. This
+ * is the one suitable for a production environment. It is safe to use the
+ * incoming context from the search directly:
+ *
+ * <pre>
+ * public Result search(Query query, Execution execution) {
+ * SearchChain searchChain = fancyChainSelectionRoutine(query);
+ * if (searchChain != null) {
+ * return new Execution(searchChain, execution.context());
+ * else {
+ * return execution.search(query);
+ * }
+ * }
+ * </pre>
+ *
+ * @param searchChain
+ * the search chain to execute
+ * @param context
+ * the execution context from which this is populated (the given
+ * context is not changed nor retained by this), or null to not
+ * populate from a context
+ * @throws IllegalArgumentException
+ * if searchChain is null
+ */
+ public Execution(Chain<? extends Searcher> searchChain, Context context) {
+ this(searchChain, context, 0);
+ }
+
+ /** Creates an execution from a single searcher */
+ public Execution(Searcher searcher, Context context) {
+ this(new Chain<>(searcher), context, 0);
+ }
+
+ /**
+ * Creates a new execution for a search chain or a single searcher. private
+ * to ensure only searchChain or searcher is null (and because it's long and
+ * cumbersome).
+ *
+ * @param searchChain
+ * the search chain to execute, must be null if searcher is set
+ * @param context
+ * execution context for the search
+ * @param searcherIndex
+ * index of the first searcher to invoke, see
+ * Execution(Execution)
+ * @throws IllegalArgumentException
+ * if searchChain is null
+ */
+ @SuppressWarnings("unchecked")
+ private Execution(Chain<? extends Processor> searchChain,Context context, int searcherIndex) {
+ // Create a new Execution which is placed in the context of the execution of the given Context if any
+ // "if any" because a context may, or may not, belong to an execution.
+ // This is decided at the creation time of the Context - Context instances which do not belong
+ // to an execution plays the role of data carriers between executions.
+ super(searchChain,searcherIndex,context.createChildTrace(),context.createChildEnvironment());
+ this.context.fill(context);
+ contextCache = new Context[searchChain.components().size()];
+ entryIndex=searcherIndex;
+ timer = new TimeTracker(searchChain, searcherIndex);
+ }
+
+ /** Does return search(((Query)request) */
+ @Override
+ public final Response process(Request request) {
+ return search((Query)request);
+ }
+
+ /** Calls search on the next searcher in this chain. If there is no next, an empty result is returned. */
+ public Result search(Query query) {
+ timer.sampleSearch(nextIndex(), context.getDetailedDiagnostics());
+
+ // Transfer state between query and execution as the execution constructors does not do that completely
+ query.getModel().setExecution(this);
+ trace().setTraceLevel(query.getTraceLevel());
+
+ return (Result)super.process(query);
+ }
+
+ @Override
+ protected void onInvoking(Request request, Processor processor) {
+ super.onInvoking(request,processor);
+ final int traceDependencies = 6;
+ Query query = (Query) request;
+ if (query.getTraceLevel() >= traceDependencies) {
+ query.trace(new StringBuilder().append(processor.getId())
+ .append(" ").append(processor.getDependencies().toString())
+ .toString(), traceDependencies);
+ }
+ }
+
+ /**
+ * The default response returned from this kind of execution when there are not further processors
+ * - an empty Result
+ */
+ @Override
+ protected Response defaultResponse(Request request) {
+ return new Result((Query)request);
+ }
+
+ /**
+ * Fill hit properties with values from all in-memory attributes.
+ * This can be done with good performance on many more hits than
+ * those for which fill is called with the final summary class, so
+ * if filtering can be done using only in-memory attribute data,
+ * this method should be preferred over {@link #fill} to get that data for filtering.
+ * <p>
+ * Calling this on already filled results has no cost.
+ *
+ * @param result the result to fill
+ */
+ @SuppressWarnings("deprecation")
+ public void fillAttributes(Result result) {
+ fill(result, ATTRIBUTEPREFETCH);
+ }
+
+ /**
+ * Fill hit properties with data using the default summary
+ * class, possibly overridden with the 'summary' request parameter.
+ * <p>
+ * Fill <b>must</b> be called before any property (accessed by
+ * getProperty/getField) is accessed on the hit. It should be done
+ * as late as possible for performance reasons.
+ * <p>
+ * Calling this on already filled results has no cost.
+ *
+ * @param result the result to fill
+ */
+ public void fill(Result result) {
+ fill(result, result.getQuery().getPresentation().getSummary());
+ }
+
+ /** Calls fill on the next searcher in this chain. If there is no next, nothing is done. */
+ public void fill(Result result,String summaryClass) {
+ timer.sampleFill(nextIndex(), context.getDetailedDiagnostics());
+ Searcher next = (Searcher)next(); // TODO: Allow but skip processors which are not searchers
+ if (next==null) return;
+
+ try {
+ nextProcessor();
+ next.ensureFilled(result, summaryClass, this);
+ }
+ finally {
+ previousProcessor();
+ timer.sampleFillReturn(nextIndex(), context.getDetailedDiagnostics(), result);
+ }
+ }
+
+ /** Calls ping on the next search in this chain. If there is no next, a Pong is created and returned. */
+ public Pong ping(Ping ping) {
+ // return this reference, not directly. It's needed for adding time data
+ Pong annotationReference = null;
+
+ timer.samplePing(nextIndex(), context.getDetailedDiagnostics());
+ Searcher next = (Searcher)next(); // TODO: Allow but skip processors which are not searchers
+ if (next==null) {
+ annotationReference = new Pong();
+ return annotationReference;
+ }
+
+ try {
+ nextProcessor();
+ annotationReference = invokePing(ping, next);
+ return annotationReference;
+ }
+ finally {
+ previousProcessor();
+ timer.samplePingReturn(nextIndex(), context.getDetailedDiagnostics(), annotationReference);
+ }
+ }
+
+ @Override
+ protected void onReturning(Request request, Processor processor,Response response) {
+ super.onReturning(request, processor, response);
+ timer.sampleSearchReturn(nextIndex(), context.getDetailedDiagnostics(), (Result)response);
+ }
+
+ @Override
+ protected void previousProcessor() {
+ super.previousProcessor();
+ popContext();
+ }
+
+ @Override
+ protected void nextProcessor() {
+ pushContext();
+ super.nextProcessor();
+ }
+
+ private void popContext() {
+ context.fill(contextCache[nextIndex()]);
+ contextCache[nextIndex()] = null;
+ }
+
+ private void pushContext() {
+ final Context contextToPush;
+ // Do note: Never put this.context in the cache. It would be totally
+ // meaningless, since it's a final.
+ if (nextIndex() == entryIndex) {
+ contextToPush = context.shallowCopy();
+ } else {
+ contextToPush = context.copyIfChanged(contextCache[nextIndex() - 1]);
+ }
+ contextCache[nextIndex()] = contextToPush;
+ }
+
+ private Pong invokePing(Ping ping, Searcher next) {
+ Pong annotationReference;
+ if (next instanceof PingableSearcher) {
+ annotationReference = ((PingableSearcher) next).ping(ping, this);
+ } else {
+ annotationReference = ping(ping);
+ }
+ return annotationReference;
+ }
+
+ /**
+ * Returns the search chain registry to use with this execution. This is a
+ * snapshot taken at creation of this execution if available.
+ */
+ public SearchChainRegistry searchChainRegistry() {
+ return context.searchChainRegistry();
+ }
+
+ /**
+ * Returns the context of this execution, which contains various objects
+ * which are looked up through a memory barrier at the point this is created
+ * and which is guaranteed to be frozen during the execution of this query.
+ * <p>
+ * Note that the context itself can be changed. Such changes will be visible
+ * to downstream searchers, but not after returning from the modifying
+ * searcher. In other words, a change in the context will not be visible to
+ * the preceding searchers when the result is returned from the searcher
+ * which modified the context.
+ */
+ public Context context() {
+ return context;
+ }
+
+ /**
+ * @return the TimeTracker instance associated with this Execution
+ */
+ public TimeTracker timer() {
+ return timer;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/ForkingSearcher.java b/container-search/src/main/java/com/yahoo/search/searchchain/ForkingSearcher.java
new file mode 100644
index 00000000000..cae1ba36e6c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/ForkingSearcher.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.search.Searcher;
+
+import java.util.Collection;
+
+/**
+ * Searchers which invokes other search chains should override this.
+ *
+ * @author bratseth
+ */
+public abstract class ForkingSearcher extends Searcher {
+
+ public ForkingSearcher() {}
+
+ /** A search chain with a comment about when it is used. */
+ public static class CommentedSearchChain {
+ public final String comment;
+ public final Chain<Searcher> searchChain;
+
+ public CommentedSearchChain(String comment, Chain<Searcher> searchChain) {
+ this.comment = comment;
+ this.searchChain = searchChain;
+ }
+ }
+
+ /** Returns which searchers this searcher may forward to, for debugging and tracing */
+ public abstract Collection<CommentedSearchChain> getSearchChainsForwarded(SearchChainRegistry registry);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/FutureResult.java b/container-search/src/main/java/com/yahoo/search/searchchain/FutureResult.java
new file mode 100644
index 00000000000..877252f07e6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/FutureResult.java
@@ -0,0 +1,86 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import com.yahoo.yolean.Exceptions;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.result.ErrorMessage;
+
+/**
+ * Extends a {@code FutureTask<Result>}, with some added error handling
+ */
+public class FutureResult extends FutureTask<Result> {
+
+ private final Query query;
+
+ /** Only used for generating messages */
+ private final Execution execution;
+
+ private final static Logger log = Logger.getLogger(FutureResult.class.getName());
+
+ FutureResult(Callable<Result> callable, Execution execution, final Query query) {
+ super(callable);
+ this.query = query;
+ this.execution = execution;
+ }
+
+ @Override
+ public Result get() {
+ Result result;
+ try {
+ result = super.get();
+ }
+ catch (InterruptedException e) {
+ result = new Result(getQuery(), ErrorMessage.createUnspecifiedError(
+ "'" + execution + "' was interrupted while executing: " + Exceptions.toMessageString(e)));
+ }
+ catch (ExecutionException e) {
+ log.log(Level.WARNING,"Exception on executing " + execution + " for " + query,e);
+ result = new Result(getQuery(), ErrorMessage.createErrorInPluginSearcher(
+ "Error in '" + execution + "': " + Exceptions.toMessageString(e),
+ e.getCause()));
+ }
+ return result;
+ }
+
+ @Override
+ public Result get(long timeout, TimeUnit timeunit) {
+ Result result;
+ try {
+ result = super.get(timeout, timeunit);
+ }
+ catch (InterruptedException e) {
+ result = new Result(getQuery(), ErrorMessage.createUnspecifiedError(
+ "'" + execution + "' was interrupted while executing: " + Exceptions.toMessageString(e)));
+ }
+ catch (ExecutionException e) {
+ log.log(Level.WARNING,"Exception on executing " + execution + " for " + query, e);
+ result = new Result(getQuery(), ErrorMessage.createErrorInPluginSearcher(
+ "Error in '" + execution + "': " + Exceptions.toMessageString(e),
+ e.getCause()));
+ }
+ catch (TimeoutException e) {
+ result = new Result(getQuery(), createTimeoutError());
+ }
+ return result;
+ }
+
+ /** Returns the query used in this execution, never null */
+ public Query getQuery() {
+ return query;
+ }
+
+ ErrorMessage createTimeoutError() {
+ return ErrorMessage.createTimeout(
+ "Error executing '" + execution + "': " + " Chain timed out.");
+
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/PhaseNames.java b/container-search/src/main/java/com/yahoo/search/searchchain/PhaseNames.java
new file mode 100644
index 00000000000..96bef503e0e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/PhaseNames.java
@@ -0,0 +1,46 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+/**
+ * Helper class for ordering searchers. Searchers may use these names in their
+ * {@literal @}Before and {@literal @}After annotations, though in general
+ * a searcher should depend on some explicit functionality, not these
+ * checkpoints.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public final class PhaseNames {
+ private PhaseNames() {
+ }
+
+ /**
+ * A checkpoint where the query is not yet transformed in any way. RAW_QUERY
+ * is the first checkpoint not provided by some searcher.
+ */
+ public static final String RAW_QUERY = "rawQuery";
+
+ /**
+ * A checkpoint where as many query transformers as practically possible has
+ * been run. TRANSFORMED_QUERY is the first checkpoint after RAW_QUERY.
+ */
+ public static final String TRANSFORMED_QUERY = "transformedQuery";
+
+ /**
+ * A checkpoint where results from different backends have been flattened
+ * into a single result. BLENDED_RESULT is the first checkpoint after
+ * TRANSFORMED_QUERY.
+ */
+ public static final String BLENDED_RESULT = "blendedResult";
+
+ /**
+ * A checkpoint where data from different backends are not yet merged.
+ * UNBLENDED_RESULT is the first checkpoint after BLENDED_RESULT.
+ */
+ public static final String UNBLENDED_RESULT = "unblendedResult";
+
+ /**
+ * The last checkpoint in a search chain not provided by any searcher.
+ */
+ public static final String BACKEND = "backend";
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/SearchChain.java b/container-search/src/main/java/com/yahoo/search/searchchain/SearchChain.java
new file mode 100644
index 00000000000..457604f7ce8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/SearchChain.java
@@ -0,0 +1,85 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.component.chain.Phase;
+import com.yahoo.search.Searcher;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A named collection of searchers.
+ * <p>
+ * The searchers may have dependencies which define an ordering
+ * of the searchers of this chain.
+ * <p>
+ * Search chains may inherit the searchers of other chains and modify
+ * the inherited set of searchers.
+ * <p>
+ * Search chains may be versioned. The version and name string combined
+ * is an unique identifier of a search chain.
+ * <p>
+ * A search chain cannot be modified once constructed.
+ *
+ * @author bratseth
+ */
+public class SearchChain extends Chain<Searcher> {
+
+ public SearchChain(ComponentId id) {
+ this(id, null, null);
+ }
+
+ public SearchChain(ComponentId id, Searcher... searchers) {
+ this(id, Arrays.asList(searchers));
+ }
+
+ public SearchChain(ComponentId id, Collection<Searcher> searchers) {
+ this(id, searchers, null);
+ }
+
+ /**
+ * Creates a search chain.
+ * <p>
+ * This search chain makes a copy of the given lists before return and does not modify the argument lists.
+ * <p>
+ * The total set of searchers included in this chain will be
+ * <ul>
+ * <li>The searchers given in <code>searchers</code>.
+ * <li>Plus all searchers returned by {@link #searchers} on all search chains in <code>inherited</code>.
+ * If a searcher with a given name is present in the <code>searchers</code> list in any version, that
+ * version will be used, and a searcher with that name will never be included from an inherited search chain.
+ * If the same searcher exists in multiple inherited chains, the highest version will be used.
+ * <li>Minus all searchers, of any version, whose name exists in the <code>excluded</code> list.
+ * </ul>
+ *
+ * @param id the id of this search chain
+ * @param searchers the searchers of this chain, or null if none
+ * @param phases the phases of this chain
+ */
+ public SearchChain(ComponentId id, Collection<Searcher> searchers, Collection<Phase> phases) {
+ super(id, searchers, phases);
+ }
+
+ /** For internal use only! */
+ public SearchChain(Chain<Searcher> chain) {
+ super(chain.getId(), chain.components());
+ }
+
+ /**
+ * Returns an unmodifiable list of the searchers this search chain executs, in resolved execution order.
+ * This includes all inherited (and not excluded) searchers.
+ */
+ public List<Searcher> searchers() {
+ return components();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder b = new StringBuilder("search ");
+ b.append(super.toString());
+ return b.toString();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/SearchChainRegistry.java b/container-search/src/main/java/com/yahoo/search/searchchain/SearchChainRegistry.java
new file mode 100644
index 00000000000..9513394bc9f
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/SearchChainRegistry.java
@@ -0,0 +1,109 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.Chain;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.processing.execution.chain.ChainRegistry;
+import com.yahoo.search.Searcher;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Contains a reference to all currently known search chains.
+ * Searchers can be fetched from this from multiple threads.
+ * <p>
+ * A registry can exist in two states:
+ * <ul>
+ * <li>not frozen - in this state it can be edited freely by calling {@link #register}
+ * <li>frozen - in this state any attempt at modification throws an IlegalStateException
+ * </ul>
+ * Registries start in the first state, moves to the second on calling freeze and stays in that
+ * state for the rest of their lifetime.
+ *
+ * @author bratseth
+ */
+public class SearchChainRegistry extends ChainRegistry<Searcher> {
+
+ private final SearcherRegistry searcherRegistry;
+
+ @Override
+ public void freeze() {
+ super.freeze();
+ getSearcherRegistry().freeze();
+ }
+
+ public SearchChainRegistry() {
+ searcherRegistry = new SearcherRegistry();
+ searcherRegistry.freeze();
+ }
+
+ public SearchChainRegistry(ComponentRegistry<? extends AbstractComponent> allComponentRegistry) {
+ this.searcherRegistry = setupSearcherRegistry(allComponentRegistry);
+ }
+
+ public void register(Chain<Searcher> component) {
+ super.register(component.getId(), component);
+ }
+
+ public Chain<Searcher> unregister(Chain<Searcher> component) {
+ return super.unregister(component.getId());
+ }
+
+ private SearcherRegistry setupSearcherRegistry(ComponentRegistry<? extends AbstractComponent> allComponents) {
+ SearcherRegistry registry = new SearcherRegistry();
+ for (AbstractComponent component : allComponents.allComponents()) {
+ if (component instanceof Searcher) {
+ registry.register((Searcher) component);
+ }
+ }
+ //just freeze this right away
+ registry.freeze();
+ return registry;
+ }
+
+ public SearcherRegistry getSearcherRegistry() {
+ return searcherRegistry;
+ }
+
+ @Override
+ public SearchChain getComponent(ComponentId id) {
+ Chain<Searcher> chain = super.getComponent(id);
+ return asSearchChain(chain);
+ }
+
+ @Override
+ public SearchChain getComponent(ComponentSpecification specification) {
+ return asSearchChain(super.getComponent(specification));
+ }
+
+ public final Chain<Searcher> getChain(String componentSpecification) {
+ return super.getComponent(new ComponentSpecification(componentSpecification));
+ }
+
+ public final Chain<Searcher> getChain(ComponentId id) {
+ return super.getComponent(id);
+ }
+
+
+ @Override
+ public SearchChain getComponent(String componentSpecification) {
+ return getComponent(new ComponentSpecification(componentSpecification));
+ }
+
+ private SearchChain asSearchChain(Chain<Searcher> chain) {
+ if (chain == null) {
+ return null;
+ } else if (chain instanceof SearchChain) {
+ return (SearchChain) chain;
+ } else {
+ return new SearchChain(chain);
+ }
+ }
+
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/SearcherRegistry.java b/container-search/src/main/java/com/yahoo/search/searchchain/SearcherRegistry.java
new file mode 100644
index 00000000000..d1a4c1743d6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/SearcherRegistry.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.pagetemplates.engine.Resolver;
+
+/**
+ * A registry of searchers. This is instantiated and recycled in the context of an owning search chain registry.
+ * This class exists for legacy purposes only, to preserve the public API for retrieving searchers from Vespa 4.2.
+ *
+ * @author bratseth
+ */
+public class SearcherRegistry extends ComponentRegistry<Searcher> {
+
+ public void register(Searcher searcher) {
+ super.register(searcher.getId(), searcher);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/example/ExampleSearcher.java b/container-search/src/main/java/com/yahoo/search/searchchain/example/ExampleSearcher.java
new file mode 100644
index 00000000000..06a4096dc68
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/example/ExampleSearcher.java
@@ -0,0 +1,23 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.example;
+
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * An example searcher which adds a hit
+ *
+ * @author bratseth
+ */
+public class ExampleSearcher extends Searcher {
+
+ public @Override Result search(Query query,Execution execution) {
+ Result result=execution.search(query);
+ result.hits().add(new Hit("example",1.0,"examplesearcher"));
+ return result;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/VespaSearchers.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/VespaSearchers.java
new file mode 100644
index 00000000000..1a3790e1012
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/VespaSearchers.java
@@ -0,0 +1,84 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.model;
+
+import com.yahoo.container.bundle.BundleInstantiationSpecification;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.dependencies.Dependencies;
+import com.yahoo.component.chain.model.ChainedComponentModel;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.model.federation.FederationSearcherModel;
+import com.yahoo.search.searchchain.model.federation.FederationSearcherModel.TargetSpec;
+import org.apache.commons.collections.CollectionUtils;
+
+import java.util.*;
+
+
+/**
+ * Defines the searcher models used in the vespa and native search chains, except for federation.
+ *
+ * @author tonytv
+ */
+@SuppressWarnings({"rawtypes", "deprecation", "unchecked"})
+public class VespaSearchers {
+ public static final Collection<ChainedComponentModel> vespaSearcherModels =
+ toSearcherModels(
+ com.yahoo.prelude.querytransform.IndexCombinatorSearcher.class,
+ //com.yahoo.prelude.querytransform.LocalitySearcher.class,
+ com.yahoo.prelude.querytransform.PhrasingSearcher.class,
+ com.yahoo.prelude.searcher.FieldCollapsingSearcher.class,
+ com.yahoo.search.yql.MinimalQueryInserter.class,
+ com.yahoo.search.yql.FieldFilter.class,
+ com.yahoo.prelude.searcher.JuniperSearcher.class,
+ com.yahoo.prelude.searcher.BlendingSearcher.class,
+ com.yahoo.prelude.searcher.PosSearcher.class,
+ com.yahoo.prelude.semantics.SemanticSearcher.class,
+ com.yahoo.search.grouping.GroupingQueryParser.class);
+
+
+ public static final Collection<ChainedComponentModel> nativeSearcherModels;
+
+ static {
+ nativeSearcherModels = new LinkedHashSet<>();
+ nativeSearcherModels.add(federationSearcherModel());
+ nativeSearcherModels.addAll(toSearcherModels(com.yahoo.prelude.statistics.StatisticsSearcher.class));
+
+ //ensure that searchers in the native search chain are not overridden by searchers in the vespa search chain,
+ //and that all component ids in each chain are unique.
+ assert(allComponentIdsDifferent(vespaSearcherModels, nativeSearcherModels));
+ }
+
+ private static boolean allComponentIdsDifferent(Collection<ChainedComponentModel> vespaSearcherModels,
+ Collection<ChainedComponentModel> nativeSearcherModels) {
+ Set<ComponentId> componentIds = new LinkedHashSet<>();
+ return
+ allAdded(vespaSearcherModels, componentIds) &&
+ allAdded(nativeSearcherModels, componentIds);
+
+ }
+
+ private static FederationSearcherModel federationSearcherModel() {
+ return new FederationSearcherModel(new ComponentSpecification("federation"),
+ Dependencies.emptyDependencies(),
+ Collections.<TargetSpec>emptyList(), true);
+ }
+
+ private static boolean allAdded(Collection<ChainedComponentModel> searcherModels, Set<ComponentId> componentIds) {
+ for (ChainedComponentModel model : searcherModels) {
+ if (!componentIds.add(model.getComponentId()))
+ return false;
+ }
+ return true;
+ }
+
+ private static Collection<ChainedComponentModel> toSearcherModels(Class<? extends Searcher>... searchers) {
+ List<ChainedComponentModel> searcherModels = new ArrayList<>();
+ for (Class c : searchers) {
+ searcherModels.add(
+ new ChainedComponentModel(
+ BundleInstantiationSpecification.getInternalSearcherSpecificationFromStrings(c.getName(), null),
+ Dependencies.emptyDependencies()));
+ }
+ return searcherModels;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationOptions.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationOptions.java
new file mode 100644
index 00000000000..ec6bf9661c6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationOptions.java
@@ -0,0 +1,123 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.model.federation;
+
+import net.jcip.annotations.Immutable;
+
+/**
+ * Options for controlling federation to a single source.
+ *
+ * @author tonytv
+ */
+@Immutable
+public class FederationOptions implements Cloneable {
+
+ private final Boolean optional;
+ private final Integer timeoutInMilliseconds;
+ private final Integer requestTimeoutInMilliseconds;
+ private final Boolean useByDefault;
+
+ /**
+ * Creates a request with no separate requestTimeoutInMilliseconds
+ */
+ public FederationOptions(Boolean optional, Integer timeoutInMilliseconds, Boolean useByDefault) {
+ this(optional, timeoutInMilliseconds, null, useByDefault);
+ }
+
+ /**
+ * Creates a fully specified set of options
+ *
+ * @param optional whether this should be optional
+ * @param timeoutInMilliseconds the max time to wait for a result from this source, or null to not specify a limit
+ * @param requestTimeoutInMilliseconds the max time to allow this request to live, or null to make this the same as
+ * timeoutInMilliseconds. Setting this higher than timeoutInMilliseconds is
+ * useful to use queries to populate the cache of slow sources
+ * @param useByDefault whether this should be invoked by default
+ */
+ public FederationOptions(Boolean optional, Integer timeoutInMilliseconds, Integer requestTimeoutInMilliseconds, Boolean useByDefault) {
+ this.optional = optional;
+ this.timeoutInMilliseconds = timeoutInMilliseconds;
+ this.requestTimeoutInMilliseconds = requestTimeoutInMilliseconds;
+ this.useByDefault = useByDefault;
+ }
+
+ /** Creates a set of default options: Mandatory, no timeout restriction and not used by default */
+ public FederationOptions() {
+ this(null, null, null, null);
+ }
+
+ /** Returns a set of options which are the same of this but with optional set to the given value */
+ public FederationOptions setOptional(Boolean newOptional) {
+ return new FederationOptions(newOptional, timeoutInMilliseconds, requestTimeoutInMilliseconds, useByDefault);
+ }
+
+ /** Returns a set of options which are the same of this but with timeout set to the given value */
+ public FederationOptions setTimeoutInMilliseconds(Integer newTimeoutInMilliseconds) {
+ return new FederationOptions(optional, newTimeoutInMilliseconds, requestTimeoutInMilliseconds, useByDefault);
+ }
+
+ /** Returns a set of options which are the same of this but with request timeout set to the given value */
+ public FederationOptions setRequestTimeoutInMilliseconds(Integer newRequestTimeoutInMilliseconds) {
+ return new FederationOptions(optional, timeoutInMilliseconds, newRequestTimeoutInMilliseconds, useByDefault);
+ }
+
+ /** Returns a set of options which are the same of this but with default set to the given value */
+ public FederationOptions setUseByDefault(Boolean newUseByDefault) {
+ return new FederationOptions(optional, timeoutInMilliseconds, requestTimeoutInMilliseconds, newUseByDefault);
+ }
+
+ public boolean getOptional() {
+ return (optional != null) ? optional : false;
+ }
+
+ /** Returns the amount of time we should wait for this target, or -1 to use default */
+ public int getTimeoutInMilliseconds() {
+ return (timeoutInMilliseconds != null) ? timeoutInMilliseconds : -1;
+ }
+
+ /** Returns the amount of time we should allow this target execution to run, or -1 to use default */
+ public int getRequestTimeoutInMilliseconds() {
+ return (requestTimeoutInMilliseconds != null) ? requestTimeoutInMilliseconds : -1;
+ }
+
+ public long getSearchChainExecutionTimeoutInMilliseconds(long queryTimeout) {
+ return getTimeoutInMilliseconds() >= 0 ?
+ getTimeoutInMilliseconds() :
+ queryTimeout;
+ }
+
+ public boolean getUseByDefault() {
+ return useByDefault != null ? useByDefault : false;
+ }
+
+ public FederationOptions inherit(FederationOptions parent) {
+ return new FederationOptions(
+ inherit(optional, parent.optional),
+ inherit(timeoutInMilliseconds, parent.timeoutInMilliseconds),
+ inherit(requestTimeoutInMilliseconds, parent.requestTimeoutInMilliseconds),
+ inherit(useByDefault, parent.useByDefault));
+ }
+
+ private static <T> T inherit(T child, T parent) {
+ return (child != null) ? child : parent;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return (other instanceof FederationOptions) &&
+ equals((FederationOptions) other);
+ }
+
+ public boolean equals(FederationOptions other) {
+ return getOptional() == other.getOptional() &&
+ getTimeoutInMilliseconds() == other.getTimeoutInMilliseconds();
+ }
+
+ @Override
+ public String toString() {
+ return "FederationOptions{" +
+ "optional=" + optional +
+ ", timeoutInMilliseconds=" + timeoutInMilliseconds +
+ ", useByDefault=" + useByDefault +
+ '}';
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationSearcherModel.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationSearcherModel.java
new file mode 100644
index 00000000000..99293cb611b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/FederationSearcherModel.java
@@ -0,0 +1,51 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.model.federation;
+
+import java.util.List;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.container.bundle.BundleInstantiationSpecification;
+import net.jcip.annotations.Immutable;
+
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.dependencies.Dependencies;
+import com.yahoo.component.chain.model.ChainedComponentModel;
+import com.yahoo.search.federation.FederationSearcher;
+
+/**
+ * Specifies how a federation searcher is to be set up.
+ *
+ * @author tonytv
+ */
+@Immutable
+public class FederationSearcherModel extends ChainedComponentModel {
+
+ /**
+ * Specifies one or more search chains that can be addressed
+ * as a single source.
+ */
+ public static class TargetSpec {
+ public final ComponentSpecification sourceSpec;
+ public final FederationOptions federationOptions;
+
+ public TargetSpec(ComponentSpecification sourceSpec, FederationOptions federationOptions) {
+ this.sourceSpec = sourceSpec;
+ this.federationOptions = federationOptions;
+ }
+ }
+
+ private static ComponentSpecification federationSearcherComponentSpecification =
+ new ComponentSpecification(FederationSearcher.class.getName());
+
+ public final List<TargetSpec> targets;
+ public final boolean inheritDefaultSources;
+
+ public FederationSearcherModel(ComponentSpecification componentId, Dependencies dependencies,
+ List<TargetSpec> targets, boolean inheritDefaultSources) {
+ super(BundleInstantiationSpecification.getInternalSearcherSpecification(componentId, federationSearcherComponentSpecification),
+ dependencies);
+ this.inheritDefaultSources = inheritDefaultSources;
+ this.targets = ImmutableList.copyOf(targets);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/HttpProviderSpec.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/HttpProviderSpec.java
new file mode 100644
index 00000000000..33bdb54b00e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/HttpProviderSpec.java
@@ -0,0 +1,121 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.model.federation;
+
+import com.yahoo.container.bundle.BundleInstantiationSpecification;
+import net.jcip.annotations.Immutable;
+
+import com.yahoo.search.federation.http.HTTPProviderSearcher;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Specifies how a http provider is to be set up.
+ *
+ * @author tonytv
+ */
+@Immutable
+public class HttpProviderSpec {
+ public enum Type {
+ vespa(com.yahoo.search.federation.vespa.VespaSearcher.class);
+
+ Type(Class<? extends HTTPProviderSearcher> searcherClass) {
+ className = searcherClass.getName();
+ }
+
+ final String className;
+ }
+
+ // The default connection parameter values come from the config definition
+ public static class ConnectionParameters {
+ public final Double readTimeout;
+ public final Double connectionTimeout;
+ public final Double connectionPoolTimeout;
+ public final Integer retries;
+
+ public ConnectionParameters(Double readTimeout, Double connectionTimeout,
+ Double connectionPoolTimeout, Integer retries) {
+ this.readTimeout = readTimeout;
+ this.connectionTimeout = connectionTimeout;
+ this.connectionPoolTimeout = connectionPoolTimeout;
+ this.retries = retries;
+ }
+ }
+
+ public static class Node {
+ public final String host;
+ public final int port;
+
+ public Node(String host, int port) {
+ this.host = host;
+ this.port = port;
+ }
+
+ @Override
+ public String toString() {
+ return "Node{" +
+ "host='" + host + '\'' +
+ ", port=" + port +
+ '}';
+ }
+ }
+
+ public final ConnectionParameters connectionParameters;
+
+ public final Integer cacheSizeMB;
+
+ public final String path;
+ public final List<Node> nodes;
+ public final String ycaApplicationId;
+ public final Integer ycaCertificateTtl;
+ public final Integer ycaRetryWait;
+ public final Node ycaProxy;
+
+ //TODO:remove this
+ public final double cacheWeight;
+
+
+ public static BundleInstantiationSpecification toBundleInstantiationSpecification(Type type) {
+ return BundleInstantiationSpecification.getInternalSearcherSpecificationFromStrings(type.className, null);
+ }
+
+ public static boolean includesType(String typeString) {
+ for (Type type : Type.values()) {
+ if (type.name().equals(typeString)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public HttpProviderSpec(Double cacheWeight,
+ String path,
+ List<Node> nodes,
+ String ycaApplicationId,
+ Integer ycaCertificateTtl,
+ Integer ycaRetryWait,
+ Node ycaProxy,
+ Integer cacheSizeMB,
+ ConnectionParameters connectionParameters) {
+
+ final double defaultCacheWeight = 1.0d;
+ this.cacheWeight = (cacheWeight != null) ? cacheWeight : defaultCacheWeight;
+
+ this.path = path;
+ this.nodes = unmodifiable(nodes);
+ this.ycaApplicationId = ycaApplicationId;
+ this.ycaProxy = ycaProxy;
+ this.ycaCertificateTtl = ycaCertificateTtl;
+ this.ycaRetryWait = ycaRetryWait;
+ this.cacheSizeMB = cacheSizeMB;
+
+ this.connectionParameters = connectionParameters;
+ }
+
+ private List<HttpProviderSpec.Node> unmodifiable(List<HttpProviderSpec.Node> nodes) {
+ return nodes == null ?
+ Collections.<HttpProviderSpec.Node>emptyList() :
+ Collections.unmodifiableList(new ArrayList<>(nodes));
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/LocalProviderSpec.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/LocalProviderSpec.java
new file mode 100644
index 00000000000..c8847507039
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/LocalProviderSpec.java
@@ -0,0 +1,79 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.model.federation;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.container.bundle.BundleInstantiationSpecification;
+import com.yahoo.component.chain.dependencies.Dependencies;
+import com.yahoo.component.chain.model.ChainedComponentModel;
+import com.yahoo.search.Searcher;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import net.jcip.annotations.Immutable;
+
+/**
+ * Specifies how a local provider is to be set up.
+ *
+ * @author tonytv
+ */
+@Immutable
+public class LocalProviderSpec {
+ @SuppressWarnings("unchecked")
+ public static final Collection<ChainedComponentModel> searcherModels =
+ toSearcherModels(
+ com.yahoo.prelude.querytransform.CJKSearcher.class,
+ com.yahoo.search.querytransform.NGramSearcher.class,
+ com.yahoo.prelude.querytransform.LiteralBoostSearcher.class,
+ com.yahoo.prelude.querytransform.NormalizingSearcher.class,
+ com.yahoo.prelude.querytransform.StemmingSearcher.class,
+ com.yahoo.search.querytransform.VespaLowercasingSearcher.class,
+ com.yahoo.search.querytransform.DefaultPositionSearcher.class,
+ com.yahoo.search.querytransform.RangeQueryOptimizer.class,
+ com.yahoo.search.querytransform.SortingDegrader.class,
+ com.yahoo.prelude.searcher.ValidateSortingSearcher.class,
+ com.yahoo.prelude.cluster.ClusterSearcher.class,
+ com.yahoo.search.grouping.GroupingValidator.class,
+ com.yahoo.search.grouping.vespa.GroupingExecutor.class,
+ com.yahoo.prelude.querytransform.RecallSearcher.class,
+ com.yahoo.search.querytransform.WandSearcher.class,
+ com.yahoo.search.querytransform.BooleanSearcher.class,
+ com.yahoo.prelude.searcher.ValidatePredicateSearcher.class,
+ com.yahoo.search.searchers.ValidateMatchPhaseSearcher.class,
+ com.yahoo.search.yql.FieldFiller.class,
+ com.yahoo.search.searchers.InputCheckingSearcher.class);
+
+ public final String clusterName;
+
+ //TODO: make this final
+ public Integer cacheSize;
+
+ public LocalProviderSpec(String clusterName, Integer cacheSize) {
+ this.clusterName = clusterName;
+ this.cacheSize = cacheSize;
+
+ if (clusterName == null)
+ throw new IllegalArgumentException("Missing cluster name.");
+ }
+
+ public static boolean includesType(String type) {
+ return "local".equals(type);
+ }
+
+ @SafeVarargs
+ private static final Collection<ChainedComponentModel> toSearcherModels(Class<? extends Searcher>... searchers) {
+ List<ChainedComponentModel> searcherModels = new ArrayList<>();
+
+ for (Class<? extends Searcher> c : searchers) {
+ searcherModels.add(
+ new ChainedComponentModel(
+ BundleInstantiationSpecification.getInternalSearcherSpecificationFromStrings(
+ c.getName(),
+ null),
+ Dependencies.emptyDependencies()));
+ }
+
+ return ImmutableList.copyOf(searcherModels);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/package-info.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/package-info.java
new file mode 100644
index 00000000000..9642d389661
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/federation/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.search.searchchain.model.federation;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/model/package-info.java b/container-search/src/main/java/com/yahoo/search/searchchain/model/package-info.java
new file mode 100644
index 00000000000..9219eb36094
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/model/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.search.searchchain.model;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/package-info.java b/container-search/src/main/java/com/yahoo/search/searchchain/package-info.java
new file mode 100644
index 00000000000..0b1ec05abef
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Classes for composition of searchers into search chains, which are executed to produce Results for Queries.
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.searchchain;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/searchchain/testutil/DocumentSourceSearcher.java b/container-search/src/main/java/com/yahoo/search/searchchain/testutil/DocumentSourceSearcher.java
new file mode 100644
index 00000000000..a5b9c58f084
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchchain/testutil/DocumentSourceSearcher.java
@@ -0,0 +1,190 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchchain.testutil;
+
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.List;
+
+import com.yahoo.net.URI;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * <p>Implements a document source. You pass in a query and a Result
+ * set. When this Searcher is called with that query it will return
+ * that result set.</p>
+ *
+ * <p>This supports multi-phase search.</p>
+ *
+ * <p>To avoid having to add type information for the fields, a quck hack is used to
+ * support testing of attribute prefetching.
+ * Any field in the configured hits which has a name starting by attribute
+ * will be returned when attribute prefetch filling is requested.</p>
+ *
+ * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a>
+ */
+public class DocumentSourceSearcher extends Searcher {
+
+ // using null as name in the API would just be a horrid headache
+ public static final String DEFAULT_SUMMARY_CLASS = "default";
+
+ // TODO: update tests to explicitly set hits, so that the default results can be removed entirely.
+ private Result defaultFilledResult;
+
+ private Map<Query, Result> completelyFilledResults = new HashMap<>();
+ private Map<Query, Result> unFilledResults = new HashMap<>();
+ private Map<String, Set<String>> summaryClasses = new HashMap<>();
+
+ private int queryCount;
+
+ public DocumentSourceSearcher() {
+ addDefaultResults();
+ }
+
+ /**
+ * Adds a result which can be searched for and filled.
+ * Summary fields starting by "a" are attributes, others are not.
+ *
+ * @return true when replacing an existing &lt;query, result&gt; pair.
+ */
+ public boolean addResult(Query query, Result fullResult) {
+ Result emptyResult = new Result(query.clone());
+ emptyResult.setTotalHitCount(fullResult.getTotalHitCount());
+ for (Hit fullHit : fullResult.hits().asList()) {
+ Hit emptyHit = fullHit.clone();
+ emptyHit.clearFields();
+ emptyHit.setFillable();
+ emptyHit.setRelevance(fullHit.getRelevance());
+
+ emptyResult.hits().add(emptyHit);
+ }
+ unFilledResults.put(getQueryKeyClone(query), emptyResult);
+
+ if (completelyFilledResults.put(getQueryKeyClone(query), fullResult.clone()) != null) {
+ // TODO: throw exception if the key exists from before, change the method to void
+ return true;
+ }
+ return false;
+ }
+
+ public void addSummaryClass(String name, Set<String> fields) {
+ summaryClasses.put(name,fields);
+ }
+
+ public void addSummaryClassByCopy(String name, Collection<String> fields) {
+ addSummaryClass(name, new HashSet<>(fields));
+ }
+
+ private void addDefaultResults() {
+ Query q = new Query("?query=default");
+ Result r = new Result(q);
+ // These four used to assign collapseId 1,2,3,4 - re-add that if needed
+ r.hits().add(new Hit("http://default-1.html", 0));
+ r.hits().add(new Hit("http://default-2.html", 0));
+ r.hits().add(new Hit("http://default-3.html", 0));
+ r.hits().add(new Hit("http://default-4.html", 0));
+ defaultFilledResult = r;
+ addResult(q, r);
+ }
+
+ public @Override Result search(Query query, Execution execution) {
+ queryCount++;
+ Result r;
+ r = unFilledResults.get(getQueryKeyClone(query));
+ if (r == null) {
+ r = defaultFilledResult.clone();
+ } else {
+ r = r.clone();
+ }
+
+ r.setQuery(query);
+ r.hits().trim(query.getOffset(), query.getHits());
+ return r;
+ }
+
+ /**
+ * Returns a query clone which has offset and hits set to null. This is used by access to
+ * the maps using the query as key to achieve lookup independent of offset/hits value
+ */
+ private Query getQueryKeyClone(Query query) {
+ Query key=query.clone();
+ key.setWindow(0,0);
+ return key;
+ }
+
+ public @Override void fill(Result result, String summaryClass, Execution execution) {
+ Result filledResult;
+ filledResult = completelyFilledResults.get(getQueryKeyClone(result.getQuery()));
+
+ if (filledResult == null) {
+ filledResult = defaultFilledResult;
+ }
+ fillHits(filledResult,result,summaryClass);
+ }
+
+ private void fillHits(Result filledHits, Result hitsToFill, String summaryClass) {
+ Set<String> fieldsToFill = summaryClasses.get(summaryClass);
+
+ if (fieldsToFill == null ) {
+ fieldsToFill = summaryClasses.get(DEFAULT_SUMMARY_CLASS);
+ }
+
+ for (Hit hitToFill : hitsToFill.hits()) {
+ Hit filledHit = getMatchingFilledHit(hitToFill.getId(), filledHits);
+
+ if (filledHit != null) {
+ if (fieldsToFill != null) {
+ copyFieldValuesThatExist(filledHit,hitToFill,fieldsToFill);
+ } else {
+ // TODO: remove this block and update fieldsToFill above to throw an exception if no appropriate summary class is found
+ for (Map.Entry<String,Object> propertyEntry : filledHit.fields().entrySet()) {
+ hitToFill.setField(propertyEntry.getKey(),
+ propertyEntry.getValue());
+ }
+ }
+ hitToFill.setFilled(summaryClass == null ? DEFAULT_SUMMARY_CLASS : summaryClass);
+ }
+ }
+ hitsToFill.analyzeHits();
+ }
+
+ private Hit getMatchingFilledHit(URI hitToFillId, Result filledHits) {
+ Hit filledHit = null;
+
+ for ( Hit filledHitCandidate : filledHits.hits()) {
+ if ( hitToFillId == filledHitCandidate.getId() ) {
+ filledHit = filledHitCandidate;
+ break;
+ }
+ }
+ return filledHit;
+ }
+
+ private void copyFieldValuesThatExist(Hit filledHit, Hit hitToFill, Set<String> fieldsToFill) {
+ for (String fieldToFill : fieldsToFill ) {
+ if ( filledHit.getField(fieldToFill) != null ) {
+ hitToFill.setField(fieldToFill, filledHit.getField(fieldToFill));
+ }
+ }
+ }
+
+ /**
+ * Returns the number of queries made to this searcher since the last
+ * reset. For testing - not reliable if multiple threads makes
+ * queries simultaneously
+ */
+ public int getQueryCount() {
+ return queryCount;
+ }
+
+ public void resetQueryCount() {
+ queryCount=0;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchers/CacheControlSearcher.java b/container-search/src/main/java/com/yahoo/search/searchers/CacheControlSearcher.java
new file mode 100644
index 00000000000..064e38d91fc
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchers/CacheControlSearcher.java
@@ -0,0 +1,75 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchers;
+
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * Searcher that sets cache control HTTP headers in response based on query/GET parameters to
+ * control caching done by proxy/caches such as YSquid and YTS:
+ * <ul>
+ * <li>max-age=XXX - set with &amp;cachecontrol.maxage parameter
+ * <li>stale-while-revalidate=YYY - set with &amp;cachecontrol.staleage
+ * <li>no-cache - if Vespa &amp;noCache or &amp;cachecontrol.nocache parameter is set to true
+ * </ul>
+ *
+ * <p>This is controlled through the three query parameters <code>cachecontrol.maxage</code>,
+ * <code>cachecontrol.staleage</code> and <code>cachecontrol.nocache</code>, with the obvious meanings.</p>
+ *
+ * Example:
+ * <ul>
+ * <li>Request: "?query=foo&amp;cachecontrol.maxage=60&amp;cachecontrol.staleage=3600"
+ * <li>Response HTTP header: "Cache-Control: max-age=60, revalidate-while-stale=3600"
+ * </ul>
+ *
+ * Further documentation on use of Cache-Control headers:
+ * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9
+ *
+ * @author Frode Lundgren
+ */
+public class CacheControlSearcher extends Searcher {
+
+ private static final CompoundName cachecontrolNocache=new CompoundName("cachecontrol.nocache");
+ private static final CompoundName cachecontrolMaxage=new CompoundName("cachecontrol.maxage");
+ private static final CompoundName cachecontrolStaleage=new CompoundName("cachecontrol.staleage");
+
+ public static final String CACHE_CONTROL_HEADER = "Cache-Control";
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ query.trace("CacheControlSearcher: Running version $Revision$", false, 6);
+ Result result = execution.search(query);
+ query = result.getQuery();
+
+ if (result.getHeaders(true) == null) {
+ query.trace("CacheControlSearcher: No HTTP header map available - skipping searcher.", false, 5);
+ return result;
+ }
+
+ // If you specify no-cache, no further cache control headers make sense
+ if (query.properties().getBoolean(cachecontrolNocache, false) || query.getNoCache()) {
+ result.getHeaders(true).put(CACHE_CONTROL_HEADER, "no-cache");
+ query.trace("CacheControlSearcher: Added no-cache header", false, 4);
+ return result;
+ }
+
+ // Handle max-age header
+ int maxage = query.properties().getInteger(cachecontrolMaxage, -1);
+ if (maxage > 0) {
+ result.getHeaders(true).put(CACHE_CONTROL_HEADER, "max-age=" + maxage);
+ query.trace("CacheControlSearcher: Set max-age header to " + maxage, false, 4);
+ }
+
+ // Handle stale-while-revalidate header
+ int staleage = query.properties().getInteger(cachecontrolStaleage, -1);
+ if (staleage > 0) {
+ result.getHeaders(true).put(CACHE_CONTROL_HEADER, "stale-while-revalidate=" + staleage);
+ query.trace("CacheControlSearcher: Set stale-while-revalidate header to " + maxage, false, 4);
+ }
+
+ return result;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchers/ConnectionControlSearcher.java b/container-search/src/main/java/com/yahoo/search/searchers/ConnectionControlSearcher.java
new file mode 100644
index 00000000000..cdbf864f7fd
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchers/ConnectionControlSearcher.java
@@ -0,0 +1,119 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchers;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.searchchain.Execution;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+
+/**
+ * Searcher which can enforce HTTP connection close based on query properties.
+ *
+ * <p>
+ * This searcher informs the client to close a persistent HTTP connection if the
+ * connection is older than the configured max lifetime. This is done by adding
+ * the "Connection" HTTP header with the value "Close" to the result.
+ * </p>
+ *
+ * <p>
+ * The searcher reads the query property "connectioncontrol.maxlifetime", which
+ * is an integer number of seconds, to get the value for maximum connection
+ * lifetime. Setting it to zero will enforce connection close independently of
+ * the age of the connection. Typical usage would be as follows:
+ * </p>
+ *
+ * <ol>
+ * <li>Add the ConnectionControlSearcher to the default search chain of your
+ * application. (It has no special ordering considerations.)</li>
+ *
+ * <li>For the default query profile of your application, set a reasonable value
+ * for "connectioncontrol.maxlifetime". The definition of reasonable will be
+ * highly application dependent, but it should always be less than the grace
+ * period when taking the container out of production traffic.</li>
+ *
+ * <li>Deploy application. The container will now inform clients to close
+ * connections/reconnect within the configured time limit.
+ * </ol>
+ *
+ * @author frodelu
+ * @author Steinar Knutsen
+ */
+public class ConnectionControlSearcher extends Searcher {
+
+ private final String simpleName = this.getClass().getSimpleName();
+
+ private final LongSupplier clock;
+
+ private static final CompoundName KEEPALIVE_MAXLIFETIMESECONDS = new CompoundName("connectioncontrol.maxlifetime");
+ private static final String HTTP_CONNECTION_HEADER_NAME = "Connection";
+ private static final String HTTP_CONNECTION_CLOSE_ARGUMENT = "Close";
+
+ public ConnectionControlSearcher() {
+ this(() -> System.currentTimeMillis());
+ }
+
+ private ConnectionControlSearcher(LongSupplier clock) {
+ this.clock = clock;
+ }
+
+ /**
+ * Create a searcher instance suitable for unit tests.
+ *
+ * @param clock a simulated or real clock behaving similarly to System.currentTimeMillis()
+ * @return a fully initialised instance
+ */
+ public static ConnectionControlSearcher createTestInstance(LongSupplier clock) {
+ return new ConnectionControlSearcher(clock);
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ Result result = execution.search(query);
+
+ query.trace(false, 3, simpleName, " updating headers.");
+ keepAliveProcessing(query, result);
+ return result;
+ }
+
+ /**
+ * If the HTTP connection has been alive for too long, set the header
+ * "Connection: Close" to tell the client to close the connection after this
+ * request.
+ */
+ private void keepAliveProcessing(Query query, Result result) {
+ int maxLifetimeSeconds = query.properties().getInteger(KEEPALIVE_MAXLIFETIMESECONDS, -1);
+
+ if (maxLifetimeSeconds < 0) {
+ return;
+ } else if (maxLifetimeSeconds == 0) {
+ result.getHeaders(true).put(HTTP_CONNECTION_HEADER_NAME, HTTP_CONNECTION_CLOSE_ARGUMENT);
+ query.trace(false, 5, simpleName, ": Max HTTP connection lifetime set to 0; adding \"", HTTP_CONNECTION_HEADER_NAME,
+ ": ", HTTP_CONNECTION_CLOSE_ARGUMENT, "\" header");
+ } else {
+ setCloseIfLifetimeExceeded(query, result, maxLifetimeSeconds);
+ }
+ }
+
+ private void setCloseIfLifetimeExceeded(Query query, Result result, int maxLifetimeSeconds) {
+ final HttpRequest httpRequest = query.getHttpRequest();
+ if (httpRequest == null) {
+ query.trace(false, 5, simpleName, " got max lifetime = ", maxLifetimeSeconds,
+ ", but got no JDisc request. Setting no header.");
+ return;
+ }
+
+ final long connectedAtMillis = httpRequest.getJDiscRequest().getConnectedAt(TimeUnit.MILLISECONDS);
+ final long maxLifeTimeMillis = maxLifetimeSeconds * 1000L;
+ if (connectedAtMillis + maxLifeTimeMillis < clock.getAsLong()) {
+ result.getHeaders(true).put(HTTP_CONNECTION_HEADER_NAME, HTTP_CONNECTION_CLOSE_ARGUMENT);
+ query.trace(false, 5, simpleName, ": Max HTTP connection lifetime (", maxLifetimeSeconds, ") exceeded; adding \"",
+ HTTP_CONNECTION_HEADER_NAME, ": ", HTTP_CONNECTION_CLOSE_ARGUMENT, "\" header");
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchers/InputCheckingSearcher.java b/container-search/src/main/java/com/yahoo/search/searchers/InputCheckingSearcher.java
new file mode 100644
index 00000000000..d99cb72f5a3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchers/InputCheckingSearcher.java
@@ -0,0 +1,191 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchers;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import com.yahoo.log.LogLevel;
+import com.yahoo.metrics.simple.Counter;
+import com.yahoo.metrics.simple.MetricReceiver;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.PhraseItem;
+import com.yahoo.prelude.query.TermItem;
+import com.yahoo.prelude.query.WordItem;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * Check whether the query tree seems to be "well formed". In other words, run heurestics against
+ * the input data to see whether the query should sent to the search backend.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class InputCheckingSearcher extends Searcher {
+
+ private final Counter utfRejections;
+ private final Counter repeatedConsecutiveTermsInPhraseRejections;
+ private final Counter repeatedTermsInPhraseRejections;
+ private static final Logger log = Logger.getLogger(InputCheckingSearcher.class.getName());
+ private final int MAX_REPEATED_CONSECUTIVE_TERMS_IN_PHRASE = 5;
+ private final int MAX_REPEATED_TERMS_IN_PHRASE=10;
+
+ public InputCheckingSearcher(MetricReceiver metrics) {
+ utfRejections = metrics.declareCounter("double_encoded_utf8_rejections");
+ repeatedTermsInPhraseRejections = metrics.declareCounter("repeated_terms_in_phrase_rejections");
+ repeatedConsecutiveTermsInPhraseRejections = metrics.declareCounter("repeated_consecutive_terms_in_phrase_rejections");
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ try {
+ checkQuery(query);
+ } catch (IllegalArgumentException e) {
+ if (log.isLoggable(LogLevel.DEBUG)) {
+ log.log(LogLevel.DEBUG, "Rejected query \"" + query.toString() + "\" on cause of: " + e.getMessage());
+ }
+ return new Result(query, ErrorMessage.createIllegalQuery(e.getMessage()));
+ }
+ return execution.search(query);
+ }
+
+ private void checkQuery(Query query) {
+ doubleEncodedUtf8(query);
+ checkPhrases(query.getModel().getQueryTree().getRoot());
+ // add new heuristics here
+ }
+
+ private void checkPhrases(Item queryItem) {
+ if (queryItem instanceof PhraseItem) {
+ PhraseItem phrase = (PhraseItem) queryItem;
+ repeatedConsecutiveTermsInPhraseCheck(phrase);
+ repeatedTermsInPhraseCheck(phrase);
+ } else if (queryItem instanceof CompositeItem) {
+ CompositeItem asComposite = (CompositeItem) queryItem;
+ for (ListIterator<Item> i = asComposite.getItemIterator(); i.hasNext();) {
+ checkPhrases(i.next());
+ }
+ }
+ }
+
+ private void repeatedConsecutiveTermsInPhraseCheck(PhraseItem phrase) {
+ if (phrase.getItemCount() > MAX_REPEATED_CONSECUTIVE_TERMS_IN_PHRASE) {
+ String prev = null;
+ int repeatedCount = 0;
+ for (int i = 0; i < phrase.getItemCount(); ++i) {
+ Item item = phrase.getItem(i);
+ if (item instanceof TermItem) {
+ TermItem term = (TermItem) item;
+ String current = term.getIndexedString();
+ if (prev != null) {
+ if (prev.equals(current)) {
+ repeatedCount++;
+ if (repeatedCount >= MAX_REPEATED_CONSECUTIVE_TERMS_IN_PHRASE) {
+ repeatedConsecutiveTermsInPhraseRejections.add();
+ throw new IllegalArgumentException("More than " + MAX_REPEATED_CONSECUTIVE_TERMS_IN_PHRASE +
+ " ocurrences of term '" + current + "' in a row detected in phrase : " + phrase.toString());
+ }
+ } else {
+ repeatedCount = 0;
+ }
+ }
+ prev = current;
+ } else {
+ prev = null;
+ repeatedCount = 0;
+ }
+ }
+ }
+ }
+ private static final class Count {
+ private int v;
+ Count(int initial) { v = initial; }
+ void inc() { v++; }
+ int get() { return v; }
+ }
+ private void repeatedTermsInPhraseCheck(PhraseItem phrase) {
+ if (phrase.getItemCount() > MAX_REPEATED_TERMS_IN_PHRASE) {
+ Map<String, Count> repeatedCount = new HashMap<>();
+ for (int i = 0; i < phrase.getItemCount(); ++i) {
+ Item item = phrase.getItem(i);
+ if (item instanceof TermItem) {
+ TermItem term = (TermItem) item;
+ String current = term.getIndexedString();
+ Count count = repeatedCount.get(current);
+ if (count != null) {
+ if (count.get() >= MAX_REPEATED_TERMS_IN_PHRASE) {
+ repeatedTermsInPhraseRejections.add();
+ throw new IllegalArgumentException("Phrase contains more than " + MAX_REPEATED_TERMS_IN_PHRASE +
+ " occurrences of term '" + current + "' in phrase : " + phrase.toString());
+ }
+ count.inc();
+ } else {
+ repeatedCount.put(current, new Count(1));
+ }
+ }
+ }
+ }
+ }
+
+
+ private void doubleEncodedUtf8(Query query) {
+ int singleCharacterTerms = countSingleCharacterUserTerms(query.getModel().getQueryTree());
+ if (singleCharacterTerms <= 4) {
+ return;
+ }
+ String userInput = query.getModel().getQueryString();
+ ByteBuffer asOctets = ByteBuffer.allocate(userInput.length());
+ boolean asciiOnly = true;
+ for (int i = 0; i < userInput.length(); ++i) {
+ char c = userInput.charAt(i);
+ if (c > 255) {
+ return; // not double (or more) encoded
+ }
+ if (c > 127) {
+ asciiOnly = false;
+ }
+ asOctets.put((byte) c);
+ }
+ if (asciiOnly) {
+ return;
+ }
+ asOctets.flip();
+ CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ // OK, unmappable character is sort of theoretical, but added to be explicit
+ try {
+ decoder.decode(asOctets);
+ } catch (CharacterCodingException e) {
+ return;
+ }
+ utfRejections.add();
+ throw new IllegalArgumentException("The user input has been determined to be double encoded UTF-8."
+ + " Please investigate whether this is a false positive.");
+ }
+
+ private int countSingleCharacterUserTerms(Item queryItem) {
+ if (queryItem instanceof CompositeItem) {
+ int sum = 0;
+ CompositeItem asComposite = (CompositeItem) queryItem;
+ for (ListIterator<Item> i = asComposite.getItemIterator(); i.hasNext();) {
+ sum += countSingleCharacterUserTerms(i.next());
+ }
+ return sum;
+ } else if (queryItem instanceof WordItem) {
+ WordItem word = (WordItem) queryItem;
+ return (word.isFromQuery() && word.stringValue().length() == 1) ? 1 : 0;
+ } else {
+ return 0;
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchers/RateLimitingSearcher.java b/container-search/src/main/java/com/yahoo/search/searchers/RateLimitingSearcher.java
new file mode 100755
index 00000000000..95cec1d0960
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchers/RateLimitingSearcher.java
@@ -0,0 +1,219 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchers;
+
+import com.google.inject.Inject;
+import com.yahoo.cloud.config.ClusterInfoConfig;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.config.RateLimitingConfig;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.yolean.chain.Provides;
+
+import java.time.Clock;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * A simple rate limiter.
+ * <p>
+ * This takes these query parameter arguments:
+ * <ul>
+ * <li>rate.id - (String) the id of the client from rate limiting perspective
+ * <li>rate.cost - (Double) the cost Double of this query. This is read after executing the query and hence can be set
+ * by downstream searchers inspecting the result to allow differencing the cost of various queries. Default is 1.
+ * <li>rate.quota - (Double) the cost per second a particular id is allowed to consume in this system.
+ * <li>rate.idDimension - (String) the name of the rate-id dimension used when logging metrics.
+ * If this is not specified, the metric will be logged without dimensions.
+ * <li>rate.dryRun - (Boolean) emit metrics on rejected requests but don't actually reject them
+ * </ul>
+ * <p>
+ * Whenever quota is exceeded for an id this searcher will reject queries from that id by
+ * returning a result containing a status 429 error.
+ * <p>
+ * If rate.id or rate.quota is not set in Query.properties this searcher will do nothing.
+ * <p>
+ * Metrics: This will emit the count metric requestsOverQuota with the dimension [rate.idDimension=rate.id]
+ * counting rejected requests.
+ * <p>
+ * Ordering: This searcher Provides rateLimiting
+ *
+ * @author bratseth
+ */
+@Provides(RateLimitingSearcher.RATE_LIMITING)
+public class RateLimitingSearcher extends Searcher {
+
+ /** Constant containing the name this Provides - "rateLimiting", for ordering constraints */
+ public static final String RATE_LIMITING = "rateLimiting";
+
+ public static final CompoundName idKey = new CompoundName("rate.id");
+ public static final CompoundName costKey = new CompoundName("rate.cost");
+ public static final CompoundName quotaKey = new CompoundName("rate.quota");
+ public static final CompoundName idDimensionKey = new CompoundName("rate.idDimension");
+ public static final CompoundName dryRunKey = new CompoundName("rate.dryRun");
+
+ private static final String requestsOverQuotaMetricName = "requestsOverQuota";
+
+ /** Used to divide quota by nodes. Assumption: All nodes get the same share of traffic. */
+ private final int nodeCount;
+
+ /** Shared capacity across all threads. Each thread will ask for more capacity from here when they run out. */
+ private final AvailableCapacity availableCapacity;
+
+ /** Capacity already allocated to this thread */
+ private final ThreadLocal<Map<String, Double>> allocatedCapacity = new ThreadLocal<>();
+
+ /** For emitting metrics */
+ private final Metric metric;
+
+ /**
+ * How much capacity to allocate to a thread each time it runs out.
+ * A higher value means less contention and less accuracy.
+ */
+ private final double capacityIncrement;
+
+ /** How often to check for new capacity if we have run out */
+ private final double recheckForCapacityProbability;
+
+ @Inject
+ public RateLimitingSearcher(RateLimitingConfig rateLimitingConfig, ClusterInfoConfig clusterInfoConfig, Metric metric) {
+ this(rateLimitingConfig, clusterInfoConfig, metric, Clock.systemUTC());
+ }
+
+ /** For testing - allows injection of a timer to avoid depending on the system clock */
+ public RateLimitingSearcher(RateLimitingConfig rateLimitingConfig, ClusterInfoConfig clusterInfoConfig, Metric metric, Clock clock) {
+ this.capacityIncrement = rateLimitingConfig.capacityIncrement();
+ this.recheckForCapacityProbability = rateLimitingConfig.recheckForCapacityProbability();
+ this.availableCapacity = new AvailableCapacity(rateLimitingConfig.maxAvailableCapacity(), clock);
+
+ this.nodeCount = clusterInfoConfig.nodeCount();
+
+ this.metric = metric;
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ String id = query.properties().getString(idKey);
+ Double rate = query.properties().getDouble(quotaKey);
+ if (id == null || rate == null) {
+ query.trace(false, 6, "Skipping rate limiting check. Need both " + idKey + " and " + quotaKey + " set");
+ return execution.search(query);
+ }
+
+ rate = rate / nodeCount;
+
+ if (allocatedCapacity.get() == null) // new thread
+ allocatedCapacity.set(new HashMap<>());
+ if (allocatedCapacity.get().get(id) == null) // new id in this thread
+ requestCapacity(id, rate);
+
+ // Check if there is capacity available. Cannot check for exact cost as it may be computed after execution
+ // no capacity means we're over rate. Only recheck occasionally to limit synchronization.
+ if (getAllocatedCapacity(id) <= 0 && ThreadLocalRandom.current().nextDouble() < recheckForCapacityProbability) {
+ requestCapacity(id, rate);
+ }
+
+ if (rate==0 || getAllocatedCapacity(id) <= 0) { // we are still over rate: reject
+ metric.add(requestsOverQuotaMetricName, 1, createContext(query.properties().getString(idDimensionKey, ""), id));
+ if ( ! query.properties().getBoolean(dryRunKey, false))
+ return new Result(query, new ErrorMessage(429, "Too many requests", "Allowed rate: " + rate + "/s"));
+ }
+
+ Result result = execution.search(query);
+ addAllocatedCapacity(id, - query.properties().getDouble(costKey, 1.0));
+
+ if (getAllocatedCapacity(id) <= 0) // make sure we ask for more with 100% probability when first running out
+ requestCapacity(id, rate);
+
+ return result;
+ }
+
+ private Metric.Context createContext(String dimensionName, String dimensionValue) {
+ if (dimensionName.isEmpty())
+ return metric.createContext(Collections.emptyMap());
+ return metric.createContext(Collections.singletonMap(dimensionName, dimensionValue));
+ }
+
+ private double getAllocatedCapacity(String id) {
+ Double value = allocatedCapacity.get().get(id);
+ if (value == null) return 0;
+ return value;
+ }
+
+ private void addAllocatedCapacity(String id, double newCapacity) {
+ Double capacity = allocatedCapacity.get().get(id);
+ if (capacity != null)
+ newCapacity += capacity;
+ allocatedCapacity.get().put(id, newCapacity);
+ }
+
+ private void requestCapacity(String id, double rate) {
+ double minimumRequested = Math.max(0, -getAllocatedCapacity(id)); // If we are below, make sure we reach 0
+ double preferredRequested = Math.max(capacityIncrement, -getAllocatedCapacity(id));
+ addAllocatedCapacity(id, availableCapacity.request(id, minimumRequested, preferredRequested, rate));
+ }
+
+ /**
+ * This keeps track of the current "capacity" (total cost) available to each client (rate id)
+ * across all threads. Capacity is supplied at the rate per second given by the clients quota.
+ * When all the capacity is spent, no further capacity will be handed out, leading to request rejection.
+ * Capacity has a max value it will never exceed to avoid clients saving capacity for future overspending.
+ */
+ private static class AvailableCapacity {
+
+ private final double maxAvailableCapacity;
+ private final Clock clock;
+
+ private final Map<String, CapacityAllocation> available = new HashMap<>();
+
+ public AvailableCapacity(double maxAvailableCapacity, Clock clock) {
+ this.maxAvailableCapacity = maxAvailableCapacity;
+ this.clock = clock;
+ }
+
+ /** Returns an amount of capacity between 0 and the requested amount based on availability for this id */
+ public synchronized double request(String id, double minimumRequested, double preferredRequested, double rate) {
+ CapacityAllocation allocation = available.get(id);
+ if (allocation == null) {
+ allocation = new CapacityAllocation(rate, clock);
+ available.put(id, allocation);
+ }
+ return allocation.request(minimumRequested, preferredRequested, rate, maxAvailableCapacity);
+ }
+
+ }
+
+ private static class CapacityAllocation {
+
+ private double capacity;
+ private final Clock clock;
+ private long lastAllocatedTime;
+
+ public CapacityAllocation(double initialCapacity, Clock clock) {
+ this.capacity = initialCapacity;
+ this.clock = clock;
+ lastAllocatedTime = clock.millis();
+ }
+
+ public double request(double minimumRequested, double preferredRequested, double rate, double maxAvailableCapacity) {
+ if ( preferredRequested > capacity) { // attempt to allocate more
+ // rate is per second so we get rate/1000 per millisecond
+ long currentTime = clock.millis();
+ capacity += Math.min(maxAvailableCapacity, rate/1000d * (Math.max(0, currentTime - lastAllocatedTime)));
+ lastAllocatedTime = currentTime;
+ }
+ double grantedCapacity = Math.min(capacity/10, preferredRequested); // /10 to avoid stealing all capacity when low
+ if (grantedCapacity < minimumRequested)
+ grantedCapacity = Math.min(minimumRequested, capacity);
+ capacity = capacity - grantedCapacity;
+ return grantedCapacity;
+ }
+
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchers/ValidateMatchPhaseSearcher.java b/container-search/src/main/java/com/yahoo/search/searchers/ValidateMatchPhaseSearcher.java
new file mode 100644
index 00000000000..ff00c8edb9b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchers/ValidateMatchPhaseSearcher.java
@@ -0,0 +1,69 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.searchers;
+
+import com.yahoo.container.QrSearchersConfig;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.vespa.config.search.AttributesConfig;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Validates that the attribute given as match-phase override is actually a valid numeric attribute
+ * with fast-search enabled.
+ * Created by balder on 1/21/15.
+ */
+public class ValidateMatchPhaseSearcher extends Searcher {
+ private Set<String> validMatchPhaseAttributes = new HashSet<>();
+ private Set<String> validDiversityAttributes = new HashSet<>();
+ public ValidateMatchPhaseSearcher(AttributesConfig attributesConfig) {
+ for (AttributesConfig.Attribute a : attributesConfig.attribute()) {
+ if (a.fastsearch() &&
+ (a.collectiontype() == AttributesConfig.Attribute.Collectiontype.SINGLE) &&
+ isNumeric(a.datatype()))
+ {
+ validMatchPhaseAttributes.add(a.name());
+ }
+ }
+ for (AttributesConfig.Attribute a : attributesConfig.attribute()) {
+ if ((a.collectiontype() == AttributesConfig.Attribute.Collectiontype.SINGLE) &&
+ ((a.datatype() == AttributesConfig.Attribute.Datatype.STRING) || isNumeric(a.datatype())))
+ {
+ validDiversityAttributes.add(a.name());
+ }
+ }
+ }
+ private boolean isNumeric(AttributesConfig.Attribute.Datatype.Enum dt) {
+ return dt == AttributesConfig.Attribute.Datatype.DOUBLE ||
+ dt == AttributesConfig.Attribute.Datatype.FLOAT ||
+ dt == AttributesConfig.Attribute.Datatype.INT8 ||
+ dt == AttributesConfig.Attribute.Datatype.INT16 ||
+ dt == AttributesConfig.Attribute.Datatype.INT32 ||
+ dt == AttributesConfig.Attribute.Datatype.INT64;
+ }
+ @Override
+ public Result search(Query query, Execution execution) {
+ ErrorMessage e = validate(query);
+ return (e != null)
+ ? new Result(query, e)
+ : execution.search(query);
+ }
+
+ private ErrorMessage validate(Query query) {
+ String attribute = query.getRanking().getMatchPhase().getAttribute();
+ if ( attribute != null && ! validMatchPhaseAttributes.contains(attribute) ) {
+ return ErrorMessage.createInvalidQueryParameter("The attribute '" + attribute + "' is not available for match-phase. " +
+ "It must be a single value numeric attribute with fast-search.");
+ }
+ attribute = query.getRanking().getMatchPhase().getDiversity().getAttribute();
+ if (attribute != null && ! validDiversityAttributes.contains(attribute)) {
+ return ErrorMessage.createInvalidQueryParameter("The attribute '" + attribute + "' is not available for match-phase diversification. " +
+ "It must be a single value numeric or string attribute.");
+ }
+ return null;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/searchers/package-info.java b/container-search/src/main/java/com/yahoo/search/searchers/package-info.java
new file mode 100644
index 00000000000..78f1e5940a6
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/searchers/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Various useful searchers
+ */
+@ExportPackage
+@PublicApi
+package com.yahoo.search.searchers;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/statistics/ElapsedTime.java b/container-search/src/main/java/com/yahoo/search/statistics/ElapsedTime.java
new file mode 100644
index 00000000000..8cf159f5ad8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/statistics/ElapsedTime.java
@@ -0,0 +1,235 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.statistics;
+
+import com.yahoo.collections.TinyIdentitySet;
+import com.yahoo.search.statistics.TimeTracker.Activity;
+import com.yahoo.search.statistics.TimeTracker.SearcherTimer;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static com.yahoo.search.statistics.TimeTracker.Activity.*;
+
+/**
+ * Basically a collection of TimeTracker instances.
+ *
+ * <p>This class may need a lot of restructuring as actual
+ * needs are mapped out.
+ *
+ * @author <a href="steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class ElapsedTime {
+
+ // An identity set is used to make it safe to do multiple merges. This may happen if
+ // user calls Result.mergeWith() and Result.mergeWithAfterFill() on the same result
+ // with the same result as an argument too. This is slightly pathological, but better
+ // safe than sorry. It also covers in SearchHandler where the same Execution instance
+ // is used for search and fill.
+ /** A map used as a set to store the time track of all the Execution instances for a Result */
+ private Set<TimeTracker> tracks = new TinyIdentitySet<>(8);
+
+ public void add(TimeTracker track) {
+ tracks.add(track);
+ }
+
+ private long fetcher(Activity toFetch, TimeTracker fetchFrom) {
+ switch (toFetch) {
+ case SEARCH:
+ return fetchFrom.searchTime();
+ case FILL:
+ return fetchFrom.fillTime();
+ case PING:
+ return fetchFrom.pingTime();
+ default:
+ return 0L;
+ }
+
+ }
+
+ /**
+ * Give an estimate on how much of the time tracked by this
+ * instance was used fetching document contents. This will
+ * by definition be smaller than last() - first().
+ */
+ public long weightedFillTime() {
+ return weightedTime(FILL);
+ }
+
+ private long weightedTime(Activity kind) {
+ long total = 0L;
+ long elapsed = 0L;
+ long first = Long.MAX_VALUE;
+ long last = 0L;
+
+ if (tracks.isEmpty()) {
+ return 0L;
+ }
+ for (TimeTracker track : tracks) {
+ total += track.totalTime();
+ elapsed += fetcher(kind, track);
+ last = Math.max(last, track.last());
+ first = Math.min(first, track.first());
+ }
+ if (total == 0L) {
+ return 0L;
+ } else {
+ return ((last - first) * elapsed) / total;
+ }
+ }
+
+ private long absoluteTime(Activity kind) {
+ long elapsed = 0L;
+
+ if (tracks.isEmpty()) {
+ return 0L;
+ }
+ for (TimeTracker track : tracks) {
+ elapsed += fetcher(kind, track);
+ }
+ return elapsed;
+ }
+
+ /**
+ * Total amount of time spent in all threads for this Execution while
+ * fetching document contents, or preparing to fetch them.
+ */
+ public long fillTime() {
+ return absoluteTime(FILL);
+ }
+
+ /**
+ * Total amount of time spent for this ElapsedTime instance.
+ */
+ public long totalTime() {
+ long total = 0L;
+ for (TimeTracker track : tracks) {
+ total += track.totalTime();
+ }
+ return total;
+ }
+
+ /**
+ * Give a relative estimate on how much of the time tracked by this
+ * instance was used searching. This will
+ * by definition be smaller than last() - first().
+ */
+ public long weightedSearchTime() {
+ return weightedTime(SEARCH);
+ }
+
+ /**
+ * Total amount of time spent in all threads for this Execution while
+ * searching or waiting for (a) backend(s) doing (a) search(es).
+ */
+ public long searchTime() {
+ return absoluteTime(SEARCH);
+ }
+
+ /**
+ * Total amount of time spent in all threads for this Execution while
+ * pinging, or preparing to ping, a backend.
+ */
+ public long pingTime() {
+ return absoluteTime(PING);
+ }
+
+ /**
+ * Give a relative estimate on how much of the time tracked by this
+ * instance was used pinging backends. This will
+ * by definition be smaller than last() - first().
+ */
+ public long weightedPingTime() {
+ return weightedTime(PING);
+ }
+
+ /**
+ * Time stamp of start of the first event registered.
+ */
+ public long first() {
+ long first = Long.MAX_VALUE;
+ for (TimeTracker track : tracks) {
+ first = Math.min(first, track.first());
+ }
+ return first;
+ }
+
+ /**
+ * Time stamp of the end the last event registered.
+ */
+ public long last() {
+ long last = 0L;
+ for (TimeTracker track : tracks) {
+ last = Math.max(last, track.last());
+ }
+ return last;
+ }
+
+ public void merge(ElapsedTime other) {
+ for (TimeTracker t : other.tracks) {
+ add(t);
+ }
+ }
+
+ /**
+ * The time of the start of the first document fill requested.
+ */
+ public long firstFill() {
+ long first = Long.MAX_VALUE;
+ if (tracks.isEmpty()) {
+ return 0L;
+ }
+ for (TimeTracker t : tracks) {
+ long candidate = t.firstFill();
+ if (candidate == 0L) {
+ continue;
+ }
+ first = Math.min(first, t.firstFill());
+ }
+ return first;
+ }
+
+ /*
+ * Tell whether time use per searcher is available.
+ */
+ public boolean hasDetailedData() {
+ for (TimeTracker t : tracks) {
+ if (t.searcherTracking() != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public String detailedReport() {
+ Map<String, TimeTracker.SearcherTimer> raw = new LinkedHashMap<>();
+ StringBuilder report = new StringBuilder();
+ int preLen;
+ report.append("Time use per searcher: ");
+ for (TimeTracker t : tracks) {
+ if (t.searcherTracking() == null) {
+ continue;
+ }
+ SearcherTimer[] searchers = t.searcherTracking();
+ for (SearcherTimer s : searchers) {
+ SearcherTimer sum;
+ if (raw.containsKey(s.getName())) {
+ sum = raw.get(s.getName());
+ } else {
+ sum = new SearcherTimer(s.getName());
+ raw.put(s.getName(), sum);
+ }
+ sum.merge(s);
+ }
+ }
+ preLen = report.length();
+ for (TimeTracker.SearcherTimer value : raw.values()) {
+ if (report.length() > preLen) {
+ report.append(",\n ");
+ }
+ report.append(value.toString());
+ }
+ report.append(".");
+ return report.toString();
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/statistics/PeakQpsSearcher.java b/container-search/src/main/java/com/yahoo/search/statistics/PeakQpsSearcher.java
new file mode 100644
index 00000000000..e6056659c55
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/statistics/PeakQpsSearcher.java
@@ -0,0 +1,237 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.statistics;
+
+import com.yahoo.collections.Tuple2;
+import com.yahoo.concurrent.ThreadLocalDirectory;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.statistics.Callback;
+import com.yahoo.statistics.Handle;
+import com.yahoo.statistics.Statistics;
+import com.yahoo.statistics.Value;
+
+import java.util.*;
+
+/**
+ * Aggregate peak qps and expose through meta hits and/or log events.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class PeakQpsSearcher extends Searcher {
+ private final ThreadLocalDirectory<Deque<QueryRatePerSecond>, Long> directory;
+ private final Value qpsStatistics;
+ private final CompoundName propertyName;
+ private final boolean useMetaHit;
+
+ /**
+ * Meta hit which carries the peak qps and mean qps since the last time this
+ * data was requested. The URI is always "meta:qps". The data is stored as
+ * Number subclasses in the fields named by the fields PEAK_QPS and MEAN_QPS
+ * in the QpsHit class.
+ */
+ public static class QpsHit extends Hit {
+ /**
+ * Machine generated version ID for serialization.
+ */
+ private static final long serialVersionUID = 1042868845398233889L;
+
+ /**
+ * The name of the field containing mean QPS since the last measurement.
+ */
+ public static final String MEAN_QPS = "mean_qps";
+
+ /**
+ * The name of the field containing peak QPS since the last measurement.
+ */
+ public static final String PEAK_QPS = "peak_qps";
+ public static final String SCHEME = "meta";
+
+ public QpsHit(Integer peakQps, Double meanQps) {
+ super(SCHEME + ":qps");
+ setField(PEAK_QPS, peakQps);
+ setField(MEAN_QPS, meanQps);
+ }
+
+ public boolean isMeta() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return "QPS hit: Peak QPS " + getField(PEAK_QPS) + ", mean QPS " + getField(MEAN_QPS) + ".";
+ }
+ }
+
+ static class QueryRatePerSecond {
+ long when;
+ int howMany;
+
+ QueryRatePerSecond(long when) {
+ this.when = when;
+ this.howMany = 0;
+ }
+
+ void add(int x) {
+ howMany += x;
+ }
+
+ void increment() {
+ howMany += 1;
+ }
+
+ @Override
+ public String toString() {
+ return "QueryRatePerSecond(" + when + ": " + howMany + ")";
+ }
+ }
+
+ static class QueryRate implements
+ ThreadLocalDirectory.Updater<Deque<QueryRatePerSecond>, Long> {
+ @Override
+ public Deque<QueryRatePerSecond> update(
+ Deque<QueryRatePerSecond> current, Long when) {
+ QueryRatePerSecond last = current.peekLast();
+ if (last == null || last.when != when) {
+ last = new QueryRatePerSecond(when);
+ current.addLast(last);
+ }
+ last.increment();
+ return current;
+ }
+
+ @Override
+ public Deque<QueryRatePerSecond> createGenerationInstance(
+ Deque<QueryRatePerSecond> previous) {
+ if (previous == null) {
+ return new ArrayDeque<>();
+ } else {
+ return new ArrayDeque<>(previous.size());
+ }
+ }
+ }
+
+ private class Fetcher implements Callback {
+ @Override
+ public void run(Handle h, boolean firstRun) {
+ List<Deque<QueryRatePerSecond>> data = directory.fetch();
+ List<QueryRatePerSecond> chewed = merge(data);
+ for (QueryRatePerSecond qps : chewed) {
+ qpsStatistics.put((double) qps.howMany);
+ }
+ }
+ }
+
+ public PeakQpsSearcher(MeasureQpsConfig config, Statistics manager) {
+ directory = createDirectory();
+ MeasureQpsConfig.Outputmethod.Enum method = config.outputmethod();
+ if (method == MeasureQpsConfig.Outputmethod.METAHIT) {
+ useMetaHit = true;
+ propertyName = new CompoundName(config.queryproperty());
+ qpsStatistics = null;
+ } else if (method == MeasureQpsConfig.Outputmethod.STATISTICS) {
+ String event = config.eventname();
+ if (event == null || event.isEmpty()) {
+ event = getId().getName();
+ event = event.replace('.', '_');
+ }
+ qpsStatistics = new Value(event, manager, new Value.Parameters()
+ .setAppendChar('_').setLogMax(true).setLogMean(true)
+ .setLogMin(false).setLogRaw(false).setNameExtension(true)
+ .setCallback(new Fetcher()));
+ useMetaHit = false;
+ propertyName = null;
+ } else {
+ throw new IllegalArgumentException(
+ "Config definition out of sync with implementation."
+ + " No way to create output for method " + method + ".");
+ }
+ }
+
+ static ThreadLocalDirectory<Deque<QueryRatePerSecond>, Long> createDirectory() {
+ return new ThreadLocalDirectory<>(new QueryRate());
+ }
+
+ static List<QueryRatePerSecond> merge(List<Deque<QueryRatePerSecond>> measurements) {
+ List<QueryRatePerSecond> rates = new ArrayList<>();
+ while (measurements.size() > 0) {
+ Deque<Deque<QueryRatePerSecond>> consumeFrom
+ = new ArrayDeque<>(measurements.size());
+ long current = Long.MAX_VALUE;
+ for (ListIterator<Deque<QueryRatePerSecond>> i = measurements.listIterator(); i.hasNext();) {
+ Deque<QueryRatePerSecond> deck = i.next();
+ if (deck.size() == 0) {
+ i.remove();
+ continue;
+ }
+ QueryRatePerSecond threadData = deck.peekFirst();
+ if (threadData.when < current) {
+ consumeFrom.clear();
+ current = threadData.when;
+ consumeFrom.add(deck);
+ } else if (threadData.when == current) {
+ consumeFrom.add(deck);
+ }
+ }
+ if (consumeFrom.size() > 0) {
+ rates.add(consume(consumeFrom));
+ }
+ }
+ return rates;
+ }
+
+ private static QueryRatePerSecond consume(Deque<Deque<QueryRatePerSecond>> consumeFrom) {
+ Deque<QueryRatePerSecond> valueQueue = consumeFrom.pop();
+ QueryRatePerSecond value = valueQueue.pop();
+ QueryRatePerSecond thisSecond = new QueryRatePerSecond(value.when);
+ thisSecond.add(value.howMany);
+ while (consumeFrom.size() > 0) {
+ valueQueue = consumeFrom.pop();
+ value = valueQueue.pop();
+ thisSecond.add(value.howMany);
+ }
+ return thisSecond;
+
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ Result r;
+ long when = query.getStartTime() / 1000L;
+ Hit meta = null;
+ directory.update(when);
+ if (useMetaHit) {
+ if (query.properties().getBoolean(propertyName, false)) {
+ List<QueryRatePerSecond> l = merge(directory.fetch());
+ Tuple2<Integer, Double> maxAndMean = maxAndMean(l);
+ meta = new QpsHit(maxAndMean.first, maxAndMean.second);
+ }
+ }
+ r = execution.search(query);
+ if (meta != null) {
+ r.hits().add(meta);
+ }
+ return r;
+ }
+
+ private Tuple2<Integer, Double> maxAndMean(List<QueryRatePerSecond> l) {
+ int max = Integer.MIN_VALUE;
+ double sum = 0.0d;
+ if (l.size() == 0) {
+ return new Tuple2<>(Integer.valueOf(0),
+ Double.valueOf(0.0));
+ }
+ for (QueryRatePerSecond qps : l) {
+ sum += (double) qps.howMany;
+ if (qps.howMany > max) {
+ max = qps.howMany;
+ }
+ }
+ return new Tuple2<>(Integer.valueOf(max),
+ Double.valueOf(sum / (double) l.size()));
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/statistics/TimeTracker.java b/container-search/src/main/java/com/yahoo/search/statistics/TimeTracker.java
new file mode 100644
index 00000000000..6d23701b06a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/statistics/TimeTracker.java
@@ -0,0 +1,390 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.statistics;
+
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+import com.yahoo.component.chain.Chain;
+import com.yahoo.prelude.Pong;
+import com.yahoo.processing.Processor;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+
+/**
+ * A container for storing time stamps throughout the
+ * lifetime of an Execution instance.
+ *
+ * <p>Check state both when entering and exiting, to allow for arbitrary
+ * new queries anywhere inside a search chain.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public final class TimeTracker {
+
+ public enum Activity {
+ PING,
+ SEARCH,
+ FILL;
+ }
+
+ static class SearcherTimer {
+ // Searcher ID
+ private final String name;
+ // Time spent transforming query/producing result
+ private final EnumMap<Activity, Long> invoking = new EnumMap<>(Activity.class);
+ // Time spent transforming result
+ private final EnumMap<Activity, Long> returning = new EnumMap<>(Activity.class);
+
+ SearcherTimer(String name) {
+ this.name = name;
+ }
+
+ private void activityRepr(StringBuilder buffer, int preLen,
+ Map.Entry<Activity, Long> m) {
+ if (buffer.length() != preLen) {
+ buffer.append(", ");
+ }
+ buffer.append(m.getKey()).append(": ").append(m.getValue())
+ .append(" ms");
+ }
+
+ void addInvoking(Activity activity, long time) {
+ Long storedTillNow = invoking.get(activity);
+ long tillNow = getTime(storedTillNow);
+ invoking.put(activity, Long.valueOf(tillNow + time));
+ }
+
+ void addReturning(Activity activity, long time) {
+ Long storedTillNow = returning.get(activity);
+ long tillNow = getTime(storedTillNow);
+ returning.put(activity, Long.valueOf(tillNow + time));
+ }
+
+ Long getInvoking(Activity activity) {
+ return invoking.get(activity);
+ }
+
+ String getName() {
+ return name;
+ }
+
+ Long getReturning(Activity activity) {
+ return returning.get(activity);
+ }
+
+ private long getTime(Long storedTillNow) {
+ long tillNow;
+ if (storedTillNow == null) {
+ tillNow = 0L;
+ } else {
+ tillNow = storedTillNow.longValue();
+ }
+ return tillNow;
+ }
+
+ public void merge(SearcherTimer other) {
+ for (Map.Entry<Activity, Long> invokingEntry : other.invoking.entrySet()) {
+ addInvoking(invokingEntry.getKey(), invokingEntry.getValue());
+ }
+ for (Map.Entry<Activity, Long> returningEntry : other.returning.entrySet()) {
+ addReturning(returningEntry.getKey(), returningEntry.getValue());
+ }
+ }
+
+ public String toString() {
+ StringBuilder buffer = new StringBuilder();
+ int preLen;
+ buffer.append(name).append("(").append("QueryProcessing(");
+ preLen = buffer.length();
+ for (Map.Entry<Activity, Long> m : invoking.entrySet()) {
+ activityRepr(buffer, preLen, m);
+ }
+ buffer.append("), ResultProcessing(");
+ preLen = buffer.length();
+ for (Map.Entry<Activity, Long> m : returning.entrySet()) {
+ activityRepr(buffer, preLen, m);
+ }
+ buffer.append("))");
+ return buffer.toString();
+ }
+ }
+
+ static class State {
+ public final long start;
+ public final Activity activity;
+
+ State(long start, Activity activity) {
+ super();
+ this.start = start;
+ this.activity = activity;
+ }
+ }
+
+ static class Tag {
+ public final long start;
+ public final long end;
+ public final Activity activity;
+
+ Tag(long start, long end, Activity activity) {
+ super();
+ this.start = start;
+ this.end = end;
+ this.activity = activity;
+ }
+ }
+
+ static class TimeSource {
+ long now() {
+ return System.currentTimeMillis();
+ }
+ }
+
+ private State state = null;
+ private List<Tag> tags = new ArrayList<>();
+
+ private SearcherTimer[] searcherTracking = null;
+ private final Chain<? extends Processor> searchChain;
+ // whether the previous state was invoking or returning
+ private boolean invoking = true;
+ private long last = 0L;
+ private final int entryIndex;
+ TimeSource timeSource = new TimeSource();
+
+ public TimeTracker(Chain<? extends Searcher> searchChain) {
+ this(searchChain, 0);
+ }
+
+ public TimeTracker(Chain<? extends Processor> searchChain, int entryIndex) {
+ this.searchChain = searchChain;
+ this.entryIndex = entryIndex;
+ }
+
+ private void concludeState(long now) {
+ if (state == null) {
+ return;
+ }
+
+ tags.add(new Tag(state.start, now, state.activity));
+ state = null;
+ }
+
+ private void concludeStateOnExit(long now) {
+ if (now != 0L) {
+ concludeState(now);
+ } else {
+ concludeState(getNow());
+ }
+ }
+
+ private long detailedMeasurements(int searcherIndex, boolean calledAsInvoking) {
+ long now = getNow();
+ if (searcherTracking == null) {
+ initBreakdown();
+ }
+ SearcherTimer timeSpentIn = getPreviouslyRunSearcher(searcherIndex, calledAsInvoking);
+ long spent = now - last;
+ if (timeSpentIn != null && last != 0L) {
+ if (invoking) {
+ timeSpentIn.addInvoking(getActivity(), spent);
+ } else {
+ timeSpentIn.addReturning(getActivity(), spent);
+ }
+ }
+ last = now;
+ if (searcherIndex >= searcherTracking.length) {
+ // We are now outside the search chain and will go back up with the
+ // default result.
+ invoking = false;
+ } else {
+ invoking = calledAsInvoking;
+ }
+ return now;
+ }
+
+ private void enteringState(int searcherIndex, boolean detailed, final Activity activity) {
+ long now = 0L;
+ if (detailed) {
+ now = detailedMeasurements(searcherIndex, true);
+ }
+ if (isNewState(activity)) {
+ if (now == 0L) {
+ now = getNow();
+ }
+ concludeState(now);
+ initNewState(now, activity);
+ } else {
+ return;
+ }
+ }
+
+ private long fetchTime(Activity filter, Tag container) {
+ if (filter == container.activity) {
+ return container.end - container.start;
+ } else {
+ return 0L;
+ }
+ }
+
+ public long fillTime() {
+ return typedSum(Activity.FILL);
+ }
+
+ public long first() {
+ if (tags.isEmpty()) {
+ return 0L;
+ } else {
+ return tags.get(0).start;
+ }
+ }
+
+ public long firstFill() {
+ for (Tag t : tags) {
+ if (t.activity == Activity.FILL) {
+ return t.start;
+ }
+ }
+ return 0L;
+ }
+
+ private Activity getActivity() {
+ if (state == null) {
+ throw new IllegalStateException("Trying to measure an interval having only one point.");
+ }
+ return state.activity;
+ }
+
+ private long getNow() {
+ return timeSource.now();
+ }
+
+ private SearcherTimer getPreviouslyRunSearcher(int searcherIndex, boolean calledAsInvoking) {
+ if (calledAsInvoking) {
+ searcherIndex -= 1;
+ if (searcherIndex < entryIndex) {
+ return null;
+ } else {
+ return searcherTracking[searcherIndex];
+ }
+ } else {
+ return searcherTracking[searcherIndex];
+ }
+ }
+
+ private void initBreakdown() {
+ if (searcherTracking != null) {
+ throw new IllegalStateException("initBreakdown invoked"
+ + " when measurement structures are already initialized.");
+ }
+ List<? extends Processor> searchers = searchChain.components();
+ searcherTracking = new SearcherTimer[searchers.size()];
+ for (int i = 0; i < searcherTracking.length; ++i) {
+ searcherTracking[i] = new SearcherTimer(searchers.get(i).getId().stringValue());
+ }
+ }
+
+ private void initNewState(long now, Activity activity) {
+ state = new State(now, activity);
+ }
+
+ void injectTimeSource(TimeSource source) {
+ this.timeSource = source;
+ }
+
+ private boolean isNewState(Activity callPath) {
+ if (state == null) {
+ return true;
+ } else if (callPath == state.activity) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ public long last() {
+ if (tags.isEmpty()) {
+ return 0L;
+ } else {
+ return tags.get(tags.size() - 1).end;
+ }
+ }
+
+ public long pingTime() {
+ return typedSum(Activity.PING);
+ }
+
+ private long returnfromState(int searcherIndex, boolean detailed) {
+ if (detailed) {
+ return detailedMeasurements(searcherIndex, false);
+ } else {
+ return 0L;
+ }
+ }
+
+ public void sampleFill(int searcherIndex, boolean detailed) {
+ enteringState(searcherIndex, detailed, Activity.FILL);
+ }
+
+ public void sampleFillReturn(int searcherIndex, boolean detailed, Result annotationReference) {
+ ElapsedTime elapsed = getElapsedTime(annotationReference);
+ sampleReturn(searcherIndex, detailed, elapsed);
+ }
+
+ public void samplePing(int searcherIndex, boolean detailed) {
+ enteringState(searcherIndex, detailed, Activity.PING);
+ }
+
+ public void samplePingReturn(int searcherIndex, boolean detailed, Pong annotationReference) {
+ ElapsedTime elapsed = getElapsedTime(annotationReference);
+ sampleReturn(searcherIndex, detailed, elapsed);
+ }
+
+ public void sampleSearch(int searcherIndex, boolean detailed) {
+ enteringState(searcherIndex, detailed, Activity.SEARCH);
+ }
+
+ public void sampleSearchReturn(int searcherIndex, boolean detailed, Result annotationReference) {
+ ElapsedTime elapsed = getElapsedTime(annotationReference);
+ sampleReturn(searcherIndex, detailed, elapsed);
+ }
+
+ private void sampleReturn(int searcherIndex, boolean detailed, ElapsedTime elapsed) {
+ long now = returnfromState(searcherIndex, detailed);
+ if (searcherIndex == entryIndex) {
+ concludeStateOnExit(now);
+ if (elapsed != null) {
+ elapsed.add(this);
+ }
+ }
+ }
+
+ private ElapsedTime getElapsedTime(Result r) {
+ return r == null ? null : r.getElapsedTime();
+ }
+
+ private ElapsedTime getElapsedTime(Pong p) {
+ return p == null ? null : p.getElapsedTime();
+ }
+
+ SearcherTimer[] searcherTracking() {
+ return searcherTracking;
+ }
+
+ public long searchTime() {
+ return typedSum(Activity.SEARCH);
+ }
+
+ public long totalTime() {
+ return last() - first();
+ }
+
+ private long typedSum(Activity activity) {
+ long sum = 0L;
+ for (Tag tag : tags) {
+ sum += fetchTime(activity, tag);
+ }
+ return sum;
+ }
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/statistics/TimingSearcher.java b/container-search/src/main/java/com/yahoo/search/statistics/TimingSearcher.java
new file mode 100644
index 00000000000..0b16c87df07
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/statistics/TimingSearcher.java
@@ -0,0 +1,144 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.statistics;
+
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.search.statistics.TimingSearcherConfig.Timer;
+import com.yahoo.prelude.Ping;
+import com.yahoo.prelude.Pong;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.cluster.PingableSearcher;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.statistics.TimeTracker.Activity;
+import com.yahoo.statistics.Statistics;
+import com.yahoo.statistics.Value;
+
+
+/**
+ * A searcher which is intended to be useful as a general probe for
+ * measuring time consumption a search chain.
+ *
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@Before("rawQuery")
+public class TimingSearcher extends PingableSearcher {
+ private Value measurements;
+ private final boolean measurePing;
+ private final boolean measureSearch;
+ private final boolean measureFill;
+ private static final Parameters defaultParameters = new Parameters(null, Activity.SEARCH);
+
+ public static class Parameters {
+ final String eventName;
+ final Activity pathToSample;
+
+ public Parameters(String eventName, Activity pathToSample) {
+ super();
+ this.eventName = eventName;
+ this.pathToSample = pathToSample;
+ }
+ }
+
+ TimingSearcher(ComponentId id, Parameters setUp, Statistics manager) {
+ super(id);
+ if (setUp == null) {
+ setUp = defaultParameters;
+ }
+ String eventName = setUp.eventName;
+ if (eventName == null || "".equals(eventName)) {
+ eventName = id.getName();
+ }
+ measurements = new Value(eventName, manager, new Value.Parameters()
+ .setNameExtension(true).setLogMax(true).setLogMin(true)
+ .setLogMean(true).setLogSum(true).setLogInsertions(true)
+ .setAppendChar('_'));
+
+ measurePing = setUp.pathToSample == Activity.PING;
+ measureSearch = setUp.pathToSample == Activity.SEARCH;
+ measureFill = setUp.pathToSample == Activity.FILL;
+ }
+
+ public TimingSearcher(ComponentId id, TimingSearcherConfig config, Statistics manager) {
+ this(id, buildParameters(config, id.getName()), manager);
+ }
+
+ private static Parameters buildParameters(
+ TimingSearcherConfig config, String searcherName) {
+ for (int i = 0; i < config.timer().size(); ++i) {
+ Timer t = config.timer(i);
+ if (t.name().equals(searcherName)) {
+ return buildParameters(t);
+ }
+ }
+ return null;
+ }
+
+ private static Parameters buildParameters(Timer t) {
+ Activity m;
+ Timer.Measure.Enum toSample = t.measure();
+ if (toSample == Timer.Measure.FILL) {
+ m = Activity.FILL;
+ } else if (toSample == Timer.Measure.PING) {
+ m = Activity.PING;
+ } else {
+ m = Activity.SEARCH;
+ }
+ return new Parameters(t.eventname(), m);
+ }
+
+ private long preMeasure(boolean doIt) {
+ if (doIt) {
+ return System.currentTimeMillis();
+ } else {
+ return 0L;
+ }
+ }
+
+ private void postMeasure(boolean doIt, long start) {
+ if (doIt) {
+ long elapsed = System.currentTimeMillis() - start;
+ measurements.put(elapsed);
+ }
+ }
+
+ @Override
+ public void fill(Result result, String summaryClass, Execution execution) {
+ long start = preMeasure(measureFill);
+ super.fill(result, summaryClass, execution);
+ postMeasure(measureFill, start);
+ }
+
+ @Override
+ public Pong ping(Ping ping, Execution execution) {
+ long start = preMeasure(measurePing);
+ Pong pong = execution.ping(ping);
+ postMeasure(measurePing, start);
+ return pong;
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ long start = preMeasure(measureSearch);
+ Result result = execution.search(query);
+ postMeasure(measureSearch, start);
+ return result;
+ }
+
+ /**
+ * This method is only included for testing.
+ */
+ public void setMeasurements(Value measurements) {
+ this.measurements = measurements;
+ }
+
+ @Override
+ public void deconstruct() {
+ // avoid dangling, duplicate loggers
+ measurements.cancel();
+ super.deconstruct();
+ }
+
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/statistics/package-info.java b/container-search/src/main/java/com/yahoo/search/statistics/package-info.java
new file mode 100644
index 00000000000..04626fa913e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/statistics/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+@PublicApi
+package com.yahoo.search.statistics;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-search/src/main/java/com/yahoo/search/template/.gitignore b/container-search/src/main/java/com/yahoo/search/template/.gitignore
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/template/.gitignore
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ArgumentsTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/ArgumentsTypeChecker.java
new file mode 100644
index 00000000000..c297bf80cac
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ArgumentsTypeChecker.java
@@ -0,0 +1,30 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Preconditions;
+
+import java.util.List;
+
+final class ArgumentsTypeChecker {
+
+ private final Operator target;
+ private final List<OperatorTypeChecker> checkers;
+
+ public ArgumentsTypeChecker(Operator target, List<OperatorTypeChecker> checkers) {
+ this.target = target;
+ this.checkers = checkers;
+ }
+
+ public void check(Object... args) {
+ if (args == null) {
+ Preconditions.checkArgument(checkers.size() == 0, "Operator %s argument count mismatch: expected %s got 0", target, checkers.size());
+ return;
+ } else {
+ Preconditions.checkArgument(args.length == checkers.size(), "Operator %s argument count mismatch: expected: %s got %s", target, checkers.size(), args.length);
+ }
+ for (int i = 0; i < checkers.size(); ++i) {
+ checkers.get(i).check(args[i]);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveFileStream.java b/container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveFileStream.java
new file mode 100644
index 00000000000..33e684357af
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveFileStream.java
@@ -0,0 +1,38 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import org.antlr.v4.runtime.ANTLRFileStream;
+import org.antlr.v4.runtime.CharStream;
+
+import java.io.IOException;
+
+/**
+ * Enable ANTLR to do case insensitive comparisons when reading from files without throwing away the case in the token.
+ */
+
+class CaseInsensitiveFileStream extends ANTLRFileStream {
+
+ public CaseInsensitiveFileStream(String fileName) throws IOException {
+ super(fileName);
+ }
+
+ public CaseInsensitiveFileStream(String fileName, String encoding) throws IOException {
+ super(fileName, encoding);
+ }
+
+ @Override
+ public int LA(int i) {
+ if (i == 0) {
+ return 0;
+ }
+ if (i < 0) {
+ i++; // e.g., translate LA(-1) to use offset 0
+ }
+
+ if ((p + i - 1) >= n) {
+ return CharStream.EOF;
+ }
+ return Character.toLowerCase(data[p + i - 1]);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveInputStream.java b/container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveInputStream.java
new file mode 100644
index 00000000000..e15fe04bb39
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/CaseInsensitiveInputStream.java
@@ -0,0 +1,50 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import org.antlr.v4.runtime.ANTLRInputStream;
+import org.antlr.v4.runtime.CharStream;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Enable ANTLR to do case insensitive comparisons when reading from files without throwing away the case in the token.
+ */
+class CaseInsensitiveInputStream extends ANTLRInputStream {
+
+ public CaseInsensitiveInputStream() {
+ super();
+ }
+
+ public CaseInsensitiveInputStream(InputStream input) throws IOException {
+ super(input);
+ }
+
+ public CaseInsensitiveInputStream(InputStream input, int size) throws IOException {
+ super(input, size);
+ }
+
+ public CaseInsensitiveInputStream(char[] data, int numberOfActualCharsInArray) throws IOException {
+ super(data, numberOfActualCharsInArray);
+ }
+
+ public CaseInsensitiveInputStream(String input) throws IOException {
+ super(input);
+ }
+
+ @Override
+ public int LA(int i) {
+ if (i == 0) {
+ return 0;
+ }
+ if (i < 0) {
+ i++; // e.g., translate LA(-1) to use offset 0
+ }
+
+ if ((p + i - 1) >= n) {
+ return CharStream.EOF;
+ }
+ return Character.toLowerCase(data[p + i - 1]);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ExpressionOperator.java b/container-search/src/main/java/com/yahoo/search/yql/ExpressionOperator.java
new file mode 100644
index 00000000000..e9fe52d33e7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ExpressionOperator.java
@@ -0,0 +1,84 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Predicate;
+
+/**
+ * Operators on expressions.
+ */
+enum ExpressionOperator implements Operator {
+
+ AND(TypeCheckers.EXPRS),
+ OR(TypeCheckers.EXPRS),
+ EQ(ExpressionOperator.class, ExpressionOperator.class),
+ NEQ(ExpressionOperator.class, ExpressionOperator.class),
+ LT(ExpressionOperator.class, ExpressionOperator.class),
+ GT(ExpressionOperator.class, ExpressionOperator.class),
+ LTEQ(ExpressionOperator.class, ExpressionOperator.class),
+ GTEQ(ExpressionOperator.class, ExpressionOperator.class),
+
+ IN(ExpressionOperator.class, ExpressionOperator.class),
+ IN_QUERY(ExpressionOperator.class, SequenceOperator.class),
+ NOT_IN(ExpressionOperator.class, ExpressionOperator.class),
+ NOT_IN_QUERY(ExpressionOperator.class, SequenceOperator.class),
+
+ LIKE(ExpressionOperator.class, ExpressionOperator.class),
+ NOT_LIKE(ExpressionOperator.class, ExpressionOperator.class),
+
+ IS_NULL(ExpressionOperator.class),
+ IS_NOT_NULL(ExpressionOperator.class),
+ MATCHES(ExpressionOperator.class, ExpressionOperator.class),
+ NOT_MATCHES(ExpressionOperator.class, ExpressionOperator.class),
+ CONTAINS(ExpressionOperator.class, ExpressionOperator.class),
+
+ ADD(ExpressionOperator.class, ExpressionOperator.class),
+ SUB(ExpressionOperator.class, ExpressionOperator.class),
+ MULT(ExpressionOperator.class, ExpressionOperator.class),
+ DIV(ExpressionOperator.class, ExpressionOperator.class),
+ MOD(ExpressionOperator.class, ExpressionOperator.class),
+
+ NEGATE(ExpressionOperator.class),
+ NOT(ExpressionOperator.class),
+
+ MAP(TypeCheckers.LIST_OF_STRING, TypeCheckers.EXPRS),
+
+ ARRAY(TypeCheckers.EXPRS),
+
+ INDEX(ExpressionOperator.class, ExpressionOperator.class),
+ PROPREF(ExpressionOperator.class, String.class),
+
+ CALL(TypeCheckers.LIST_OF_STRING, TypeCheckers.EXPRS),
+
+ VARREF(String.class),
+
+ LITERAL(TypeCheckers.LITERAL_TYPES),
+
+ READ_RECORD(String.class),
+ READ_FIELD(String.class, String.class),
+ READ_MODULE(TypeCheckers.LIST_OF_STRING),
+
+ VESPA_GROUPING(String.class),
+
+ NULL();
+
+ private final ArgumentsTypeChecker checker;
+
+
+ private ExpressionOperator(Object... types) {
+ checker = TypeCheckers.make(this, types);
+ }
+
+
+ @Override
+ public void checkArguments(Object... args) {
+ checker.check(args);
+ }
+
+ public static Predicate<OperatorNode<? extends Operator>> IS = new Predicate<OperatorNode<? extends Operator>>() {
+ @Override
+ public boolean apply(OperatorNode<? extends Operator> input) {
+ return input.getOperator() instanceof ExpressionOperator;
+ }
+ };
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/FieldFiller.java b/container-search/src/main/java/com/yahoo/search/yql/FieldFiller.java
new file mode 100644
index 00000000000..f6e8ee1f27a
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/FieldFiller.java
@@ -0,0 +1,156 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import com.google.common.annotations.Beta;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.prelude.fastsearch.DocumentdbInfoConfig;
+import com.yahoo.prelude.fastsearch.DocumentdbInfoConfig.Documentdb;
+import com.yahoo.prelude.fastsearch.DocumentdbInfoConfig.Documentdb.Summaryclass;
+import com.yahoo.prelude.fastsearch.DocumentdbInfoConfig.Documentdb.Summaryclass.Fields;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.query.Presentation;
+import com.yahoo.search.searchchain.Execution;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+/**
+ * Ensure the fields specified in {@link Presentation#getSummaryFields()} are
+ * available after filling phase.
+ *
+ * @author <a href="mailto:stiankri@yahoo-inc.com">Stian Kristoffersen</a>
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@Beta
+@After(MinimalQueryInserter.EXTERNAL_YQL)
+public class FieldFiller extends Searcher {
+
+ private final Set<String> intersectionOfAttributes;
+ private final SummaryIntersections summaryDb = new SummaryIntersections();
+ public static final CompoundName FIELD_FILLER_DISABLE = new CompoundName(
+ "FieldFiller.disable");
+
+ private static class SummaryIntersections {
+ private final Map<String, Map<String, Set<String>>> db = new HashMap<>();
+
+ void add(String dbName, Summaryclass summary) {
+ Map<String, Set<String>> docType = getOrCreateDocType(dbName);
+ Set<String> fields = new HashSet<>(summary.fields().size());
+ for (Fields f : summary.fields()) {
+ fields.add(f.name());
+ }
+ docType.put(summary.name(), fields);
+ }
+
+ @NonNull
+ private Map<String, Set<String>> getOrCreateDocType(String dbName) {
+ Map<String, Set<String>> docType = db.get(dbName);
+ if (docType == null) {
+ docType = new HashMap<>();
+ db.put(dbName, docType);
+ }
+ return docType;
+ }
+
+ boolean hasAll(Set<String> requested, String summaryName, Set<String> restrict) {
+ Set<String> explicitRestriction;
+ Set<String> intersection = null;
+
+ if (restrict.isEmpty()) {
+ explicitRestriction = db.keySet();
+ } else {
+ explicitRestriction = restrict;
+ }
+
+ for (String docType : explicitRestriction) {
+ Map<String, Set<String>> summaries = db.get(docType);
+ Set<String> summary;
+
+ if (summaries == null) {
+ continue;
+ }
+ summary = summaries.get(summaryName);
+ if (summary == null) {
+ intersection = null;
+ break;
+ }
+ if (intersection == null) {
+ intersection = new HashSet<>(summary.size());
+ intersection.addAll(summary);
+ } else {
+ intersection.retainAll(summary);
+ }
+ }
+ return intersection == null ? false : intersection
+ .containsAll(requested);
+ }
+ }
+
+ public FieldFiller(DocumentdbInfoConfig config) {
+ intersectionOfAttributes = new HashSet<>();
+ boolean first = true;
+
+ for (Documentdb db : config.documentdb()) {
+ for (Summaryclass summary : db.summaryclass()) {
+ Set<String> attributes = null;
+ if (Execution.ATTRIBUTEPREFETCH.equals(summary.name())) {
+ attributes = new HashSet<>(summary.fields().size());
+ for (Fields f : summary.fields()) {
+ attributes.add(f.name());
+ }
+ if (first) {
+ first = false;
+ intersectionOfAttributes.addAll(attributes);
+ } else {
+ intersectionOfAttributes.retainAll(attributes);
+ }
+ }
+ // yes, we store attribute prefetch here as well, this is in
+ // case we get a query where we have a restrict parameter which
+ // makes filling with attribute prefetch possible even though it
+ // wouldn't have been possible without restricting the set of
+ // doctypes
+ summaryDb.add(db.name(), summary);
+ }
+ }
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ return execution.search(query);
+ }
+
+ @Override
+ public void fill(Result result, String summaryClass, Execution execution) {
+ execution.fill(result, summaryClass);
+
+ final Set<String> summaryFields = result.getQuery().getPresentation()
+ .getSummaryFields();
+
+ if (summaryFields.isEmpty()
+ || summaryClass == null
+ || result.getQuery().properties()
+ .getBoolean(FIELD_FILLER_DISABLE)) {
+ return;
+ }
+
+ if (intersectionOfAttributes.containsAll(summaryFields)) {
+ if (!Execution.ATTRIBUTEPREFETCH.equals(summaryClass)) {
+ execution.fill(result, Execution.ATTRIBUTEPREFETCH);
+ }
+ } else {
+ // Yes, summaryClass may be Execution.ATTRIBUTEPREFETCH here
+ if (!summaryDb.hasAll(summaryFields, summaryClass, result
+ .getQuery().getModel().getRestrict())) {
+ execution.fill(result, null);
+ }
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/FieldFilter.java b/container-search/src/main/java/com/yahoo/search/yql/FieldFilter.java
new file mode 100644
index 00000000000..b44fdadd17b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/FieldFilter.java
@@ -0,0 +1,64 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import java.util.Iterator;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import com.google.common.annotations.Beta;
+import com.yahoo.component.chain.dependencies.After;
+import com.yahoo.component.chain.dependencies.Before;
+import com.yahoo.prelude.fastsearch.FastHit;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.search.result.Hit;
+import com.yahoo.search.searchchain.Execution;
+
+/**
+ * Remove fields which are not explicitly requested, if any field is explicitly
+ * requested. Disable using FieldFilter.disable=true in request.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@Beta
+@After(MinimalQueryInserter.EXTERNAL_YQL)
+@Before("com.yahoo.search.yql.FieldFiller")
+public class FieldFilter extends Searcher {
+
+ public static final CompoundName FIELD_FILTER_DISABLE = new CompoundName("FieldFilter.disable");
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ Result result = execution.search(query);
+ filter(result);
+ return result;
+ }
+
+ @Override
+ public void fill(Result result, String summaryClass, Execution execution) {
+ execution.fill(result, summaryClass);
+ filter(result);
+ }
+
+ private void filter(Result result) {
+ Set<String> requestedFields;
+
+ if (result.getQuery().properties().getBoolean(FIELD_FILTER_DISABLE)) return;
+ if (result.getQuery().getPresentation().getSummaryFields().isEmpty()) return;
+
+ requestedFields = result.getQuery().getPresentation().getSummaryFields();
+ for (Iterator<Hit> i = result.hits().unorderedDeepIterator(); i.hasNext();) {
+ Hit h = i.next();
+ if (h.isMeta()) continue;
+ for (Iterator<Entry<String, Object>> fields = h.fieldIterator(); fields.hasNext();) {
+ Entry<String, Object> field = fields.next();
+ if ( ! requestedFields.contains(field.getKey()))
+ fields.remove();
+ }
+
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/JavaListTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/JavaListTypeChecker.java
new file mode 100644
index 00000000000..86e2cbf01ff
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/JavaListTypeChecker.java
@@ -0,0 +1,29 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Preconditions;
+
+import java.util.List;
+
+class JavaListTypeChecker extends OperatorTypeChecker {
+
+ private final Class<?> elementType;
+
+ public JavaListTypeChecker(Operator parent, int idx, Class<?> elementType) {
+ super(parent, idx);
+ this.elementType = elementType;
+ }
+
+ @Override
+ public void check(Object argument) {
+ Preconditions.checkNotNull(argument, "Argument %s of %s must not be null", idx, parent);
+ Preconditions.checkArgument(argument instanceof List, "Argument %s of %s must be a List<%s>", idx, parent, elementType.getName(), argument.getClass().getName());
+ List<?> lst = (List<?>) argument;
+ for (Object elt : lst) {
+ Preconditions.checkNotNull(elt, "Argument %s of %s List elements may not be null", idx, parent);
+ Preconditions.checkArgument(elementType.isInstance(elt), "Argument %s of %s List elements must be %s (is %s)", idx, parent, elementType.getName(), elt.getClass().getName());
+ }
+ }
+
+}
+
diff --git a/container-search/src/main/java/com/yahoo/search/yql/JavaTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/JavaTypeChecker.java
new file mode 100644
index 00000000000..bf91474c19b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/JavaTypeChecker.java
@@ -0,0 +1,21 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Preconditions;
+
+class JavaTypeChecker extends OperatorTypeChecker {
+
+ private final Class<?> type;
+
+ public JavaTypeChecker(Operator parent, int idx, Class<?> type) {
+ super(parent, idx);
+ this.type = type;
+ }
+
+ @Override
+ public void check(Object argument) {
+ Preconditions.checkNotNull(argument, "Argument %s of %s must not be null", idx, parent);
+ Preconditions.checkArgument(type.isInstance(argument), "Argument %s of %s must be %s (is: %s).", idx, parent, type.getName(), argument.getClass().getName());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/JavaUnionTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/JavaUnionTypeChecker.java
new file mode 100644
index 00000000000..a94027a9bd2
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/JavaUnionTypeChecker.java
@@ -0,0 +1,35 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Set;
+
+public class JavaUnionTypeChecker extends OperatorTypeChecker {
+
+ private final Set<Class<?>> types;
+
+ public JavaUnionTypeChecker(Operator parent, int idx, Set<Class<?>> types) {
+ super(parent, idx);
+ this.types = types;
+ }
+
+ public JavaUnionTypeChecker(Operator parent, int idx, Class<?>... types) {
+ super(parent, idx);
+ this.types = ImmutableSet.copyOf(types);
+ }
+
+ @Override
+ public void check(Object argument) {
+ Preconditions.checkNotNull(argument, "Argument %s of %s must not be null", idx, parent);
+ for (Class<?> candidate : types) {
+ if (candidate.isInstance(argument)) {
+ return;
+ }
+ }
+ Preconditions.checkArgument(false, "Argument %s of %s must be %s (is: %s).", idx, parent, Joiner.on("|").join(types), argument.getClass());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/Location.java b/container-search/src/main/java/com/yahoo/search/yql/Location.java
new file mode 100644
index 00000000000..a304ed75536
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/Location.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+/**
+ * A pointer to a location in a YQL source program.
+ */
+final class Location {
+
+ private final String programName;
+ private final int lineNumber;
+ private final int characterOffset;
+
+ public Location(String programName, int lineNumber, int characterOffset) {
+ this.programName = programName;
+ this.lineNumber = lineNumber;
+ this.characterOffset = characterOffset;
+ }
+
+
+ public int getLineNumber() {
+ return lineNumber;
+ }
+
+ public int getCharacterOffset() {
+ return characterOffset;
+ }
+
+ @Override
+ public String toString() {
+ if (programName != null) {
+ return programName + ":L" + lineNumber + ":" + characterOffset;
+ } else {
+ return "L" + lineNumber + ":" + characterOffset;
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/MinimalQueryInserter.java b/container-search/src/main/java/com/yahoo/search/yql/MinimalQueryInserter.java
new file mode 100644
index 00000000000..d710754e887
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/MinimalQueryInserter.java
@@ -0,0 +1,98 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.annotations.Beta;
+import com.yahoo.search.Query;
+import com.yahoo.search.Result;
+import com.yahoo.search.Searcher;
+import com.yahoo.processing.request.CompoundName;
+import com.yahoo.search.grouping.GroupingRequest;
+import com.yahoo.search.query.QueryTree;
+import com.yahoo.search.query.parser.Parsable;
+import com.yahoo.search.query.parser.ParserEnvironment;
+import com.yahoo.search.query.parser.ParserFactory;
+import com.yahoo.search.result.ErrorMessage;
+import com.yahoo.search.searchchain.Execution;
+import com.yahoo.search.searchchain.PhaseNames;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Before;
+import com.yahoo.yolean.chain.Provides;
+
+/**
+ * Minimal combinator for YQL+ syntax and heuristically parsed user queries.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ * @since 5.1.28
+ */
+@Beta
+@Provides(MinimalQueryInserter.EXTERNAL_YQL)
+@Before(PhaseNames.TRANSFORMED_QUERY)
+@After("com.yahoo.prelude.statistics.StatisticsSearcher")
+public class MinimalQueryInserter extends Searcher {
+ public static final String EXTERNAL_YQL = "ExternalYql";
+
+ public static final CompoundName YQL = new CompoundName("yql");
+
+ private static final CompoundName MAX_HITS = new CompoundName("maxHits");
+ private static final CompoundName MAX_OFFSET = new CompoundName("maxOffset");
+
+ public MinimalQueryInserter() {
+ }
+
+ @Override
+ public Result search(Query query, Execution execution) {
+ if (query.properties().get(YQL) == null) {
+ return execution.search(query);
+ }
+ ParserEnvironment env = ParserEnvironment.fromExecutionContext(execution.context());
+ YqlParser parser = (YqlParser) ParserFactory.newInstance(Query.Type.YQL, env);
+ parser.setQueryParser(false);
+ parser.setUserQuery(query);
+ QueryTree newTree;
+ try {
+ newTree = parser.parse(Parsable.fromQueryModel(query.getModel())
+ .setQuery(query.properties().getString(YQL)));
+ } catch (RuntimeException e) {
+ return new Result(query, ErrorMessage.createInvalidQueryParameter(
+ "Could not instantiate query from YQL+", e));
+ }
+ if (parser.getOffset() != null) {
+ final int maxHits = query.properties().getInteger(MAX_HITS);
+ final int maxOffset = query.properties().getInteger(MAX_OFFSET);
+ if (parser.getOffset() > maxOffset) {
+ return new Result(query, ErrorMessage.createInvalidQueryParameter("Requested offset " + parser.getOffset()
+ + ", but the max offset allowed is " + maxOffset + "."));
+ }
+ if (parser.getHits() > maxHits) {
+ return new Result(query, ErrorMessage.createInvalidQueryParameter("Requested " + parser.getHits()
+ + " hits returned, but max hits allowed is " + maxHits + "."));
+
+ }
+ }
+ query.getModel().getQueryTree().setRoot(newTree.getRoot());
+ query.getPresentation().getSummaryFields().addAll(parser.getYqlSummaryFields());
+ for (VespaGroupingStep step : parser.getGroupingSteps()) {
+ GroupingRequest.newInstance(query)
+ .setRootOperation(step.getOperation())
+ .continuations().addAll(step.continuations());
+ }
+ if (parser.getYqlSources().size() == 0) {
+ query.getModel().getSources().clear();
+ } else {
+ query.getModel().getSources().addAll(parser.getYqlSources());
+ }
+ if (parser.getOffset() != null) {
+ query.setOffset(parser.getOffset());
+ query.setHits(parser.getHits());
+ }
+ if (parser.getTimeout() != null) {
+ query.setTimeout(parser.getTimeout().longValue());
+ }
+ if (parser.getSorting() != null) {
+ query.getRanking().setSorting(parser.getSorting());
+ }
+ query.trace("YQL+ query parsed", true, 2);
+ return execution.search(query);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/NodeTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/NodeTypeChecker.java
new file mode 100644
index 00000000000..c407689e107
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/NodeTypeChecker.java
@@ -0,0 +1,34 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import java.util.Set;
+
+/**
+ * Check that an argument is an OperatorNode of a particular operator set.
+ */
+class NodeTypeChecker extends OperatorTypeChecker {
+
+ private final Class<? extends Operator> operatorType;
+ private final Set<? extends Operator> operators;
+
+ public NodeTypeChecker(Operator parent, int idx, Class<? extends Operator> operatorType, Set<? extends Operator> operators) {
+ super(parent, idx);
+ this.operatorType = operatorType;
+ this.operators = operators;
+ }
+
+ @Override
+ public void check(Object argument) {
+ Preconditions.checkNotNull(argument, "Argument %s of %s must not be null", idx, parent);
+ Preconditions.checkArgument(argument instanceof OperatorNode, "Argument %s of %s must be an OperatorNode<%s> (is %s).", idx, parent, operatorType.getName(), argument.getClass());
+ OperatorNode<?> node = (OperatorNode<?>) argument;
+ Operator op = node.getOperator();
+ Preconditions.checkArgument(operatorType.isInstance(op), "Argument %s of %s must be an OperatorNode<%s> (is: %s).", idx, parent, operatorType.getName(), op.getClass());
+ if (!operators.isEmpty()) {
+ Preconditions.checkArgument(operators.contains(op), "Argument %s of %s must be %s (is %s).", idx, parent, Joiner.on("|").join(operators), op);
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/NullItemException.java b/container-search/src/main/java/com/yahoo/search/yql/NullItemException.java
new file mode 100644
index 00000000000..c50f22ff711
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/NullItemException.java
@@ -0,0 +1,14 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+/**
+ * Used to communicate a NullItem has been encountered in the query tree.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+@SuppressWarnings("serial")
+public class NullItemException extends RuntimeException {
+ public NullItemException(String message) {
+ super(message);
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/Operator.java b/container-search/src/main/java/com/yahoo/search/yql/Operator.java
new file mode 100644
index 00000000000..f5c0f9fb56d
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/Operator.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+interface Operator {
+
+ String name();
+
+ void checkArguments(Object... args);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/OperatorNode.java b/container-search/src/main/java/com/yahoo/search/yql/OperatorNode.java
new file mode 100644
index 00000000000..d1b65ee258b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/OperatorNode.java
@@ -0,0 +1,261 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Function;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import javax.annotation.Nullable;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a use of an operator against concrete arguments. The types of arguments depend on the operator.
+ * <p>
+ * The extension point of this scheme is the Operator rather than new types of Nodes.
+ * <p>
+ * Operators SHOULD take a fixed number of arguments -- wrap variable argument counts in Lists.
+ */
+final class OperatorNode<T extends Operator> {
+
+ public static <T extends Operator> OperatorNode<T> create(T operator, Object... args) {
+ operator.checkArguments(args == null ? EMPTY_ARGS : args);
+ return new OperatorNode<T>(operator, args);
+ }
+
+ public static <T extends Operator> OperatorNode<T> create(Location loc, T operator, Object... args) {
+ operator.checkArguments(args == null ? EMPTY_ARGS : args);
+ return new OperatorNode<T>(loc, operator, args);
+ }
+
+ public static <T extends Operator> OperatorNode<T> create(Location loc, Map<String, Object> annotations, T operator, Object... args) {
+ operator.checkArguments(args == null ? EMPTY_ARGS : args);
+ return new OperatorNode<T>(loc, annotations, operator, args);
+ }
+
+ private static final Object[] EMPTY_ARGS = new Object[0];
+
+ private final Location location;
+ private final T operator;
+ private Map<String, Object> annotations = ImmutableMap.of();
+ private final Object[] args;
+
+ private OperatorNode(T operator, Object... args) {
+ this.location = null;
+ this.operator = operator;
+ if (args == null) {
+ this.args = EMPTY_ARGS;
+ } else {
+ this.args = args;
+ }
+ }
+
+ private OperatorNode(Location loc, T operator, Object... args) {
+ this.location = loc;
+ this.operator = operator;
+ if (args == null) {
+ this.args = EMPTY_ARGS;
+ } else {
+ this.args = args;
+ }
+ }
+
+ private OperatorNode(Location loc, Map<String, Object> annotations, T operator, Object... args) {
+ this.location = loc;
+ this.operator = operator;
+ this.annotations = ImmutableMap.copyOf(annotations);
+ if (args == null) {
+ this.args = EMPTY_ARGS;
+ } else {
+ this.args = args;
+ }
+ }
+
+ public T getOperator() {
+ return operator;
+ }
+
+ public Object[] getArguments() {
+ // this is only called by a test right now, but ImmutableList.copyOf won't tolerate null elements
+ if (args.length == 0) {
+ return args;
+ }
+ Object[] copy = new Object[args.length];
+ System.arraycopy(args, 0, copy, 0, args.length);
+ return copy;
+ }
+
+ public <T> T getArgument(int i) {
+ return (T) args[i];
+ }
+
+ public <T> T getArgument(int i, Class<T> clazz) {
+ return clazz.cast(getArgument(i));
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ public Object getAnnotation(String name) {
+ return annotations.get(name);
+ }
+
+ public OperatorNode<T> putAnnotation(String name, Object value) {
+ if (annotations.isEmpty()) {
+ annotations = Maps.newLinkedHashMap();
+ } else if (annotations instanceof ImmutableMap) {
+ annotations = Maps.newLinkedHashMap(annotations);
+ }
+ annotations.put(name, value);
+ return this;
+ }
+
+ public Map<String, Object> getAnnotations() {
+ // TODO: this should be a read-only view?
+ return ImmutableMap.copyOf(annotations);
+ }
+
+ public OperatorNode<T> transform(Function<Object, Object> argumentTransform) {
+ if (args.length == 0) {
+ // nothing to transform, so no change is possible
+ return this;
+ }
+ Object[] newArgs = new Object[args.length];
+ boolean changed = false;
+ for (int i = 0; i < args.length; ++i) {
+ Object target = args[i];
+ if (target instanceof List) {
+ List<Object> newList = Lists.newArrayListWithExpectedSize(((List) target).size());
+ for (Object val : (List) target) {
+ newList.add(argumentTransform.apply(val));
+ }
+ newArgs[i] = newList;
+ // this will always 'change' the tree, maybe fix later
+ } else {
+ newArgs[i] = argumentTransform.apply(args[i]);
+ }
+ changed = changed || newArgs[i] != args[i];
+ }
+ if (changed) {
+ return new OperatorNode<>(location, annotations, operator, newArgs);
+ }
+ return this;
+ }
+
+ public void visit(OperatorVisitor visitor) {
+ if (visitor.enter(this)) {
+ for (Object target : args) {
+ if (target instanceof List) {
+ for (Object val : (List) target) {
+ if (val instanceof OperatorNode) {
+ ((OperatorNode) val).visit(visitor);
+ }
+ }
+ } else if (target instanceof OperatorNode) {
+ ((OperatorNode) target).visit(visitor);
+
+ }
+ }
+ }
+ visitor.exit(this);
+ }
+
+ // we are aware only of types used in our logical operator trees -- OperatorNode, List, and constant values
+ private static final Function<Object, Object> COPY = new Function<Object, Object>() {
+ @Nullable
+ @Override
+ public Object apply(@Nullable Object input) {
+ if (input instanceof List) {
+ List<Object> newList = Lists.newArrayListWithExpectedSize(((List) input).size());
+ for (Object val : (List) input) {
+ newList.add(COPY.apply(val));
+ }
+ return newList;
+ } else if (input instanceof OperatorNode) {
+ return ((OperatorNode) input).copy();
+ } else if (input instanceof String || input instanceof Number || input instanceof Boolean) {
+ return input;
+ } else {
+ // this may be annoying but COPY not understanding how to COPY and quietly reusing
+ // when it may not be immutable could be dangerous
+ throw new IllegalArgumentException("Unexpected value type in OperatorNode tree: " + input);
+ }
+ }
+ };
+
+ public OperatorNode<T> copy() {
+ Object[] newArgs = new Object[args.length];
+ for (int i = 0; i < args.length; ++i) {
+ newArgs[i] = COPY.apply(args[i]);
+ }
+ return new OperatorNode<>(location, ImmutableMap.copyOf(annotations), operator, newArgs);
+ }
+
+ public void toString(StringBuilder output) {
+ output.append("(")
+ .append(operator.name());
+ if(location != null) {
+ output.append(" L")
+ .append(location.getCharacterOffset())
+ .append(":")
+ .append(location.getLineNumber());
+ }
+ if(annotations != null && !annotations.isEmpty()) {
+ output.append(" {");
+ Joiner.on(", ").withKeyValueSeparator("=")
+ .appendTo(output, annotations);
+ output.append("}");
+ }
+ boolean first = true;
+ for(Object arg : args) {
+ if(!first) {
+ output.append(",");
+ }
+ first = false;
+ output.append(" ");
+ if(arg instanceof OperatorNode) {
+ ((OperatorNode) arg).toString(output);
+ } else if(arg instanceof Iterable) {
+ output.append("[");
+ Joiner.on(", ").appendTo(output, (Iterable)arg);
+ output.append("]");
+ } else {
+ output.append(arg.toString());
+ }
+ }
+ output.append(")");
+ }
+
+ public String toString() {
+ StringBuilder output = new StringBuilder();
+ toString(output);
+ return output.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ OperatorNode that = (OperatorNode) o;
+
+ if (!annotations.equals(that.annotations)) return false;
+ // Probably incorrect - comparing Object[] arrays with Arrays.equals
+ if (!Arrays.equals(args, that.args)) return false;
+ if (!operator.equals(that.operator)) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = operator.hashCode();
+ result = 31 * result + annotations.hashCode();
+ result = 31 * result + Arrays.hashCode(args);
+ return result;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/OperatorNodeListTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/OperatorNodeListTypeChecker.java
new file mode 100644
index 00000000000..d0c98fb3d11
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/OperatorNodeListTypeChecker.java
@@ -0,0 +1,35 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+
+import java.util.List;
+import java.util.Set;
+
+class OperatorNodeListTypeChecker extends OperatorTypeChecker {
+
+ private final Class<? extends Operator> operatorType;
+ private final Set<? extends Operator> operators;
+
+ public OperatorNodeListTypeChecker(Operator parent, int idx, Class<? extends Operator> operatorType, Set<? extends Operator> operators) {
+ super(parent, idx);
+ this.operatorType = operatorType;
+ this.operators = operators;
+ }
+
+ @Override
+ public void check(Object argument) {
+ Preconditions.checkNotNull(argument, "Argument %s of %s must not be null", idx, parent);
+ Preconditions.checkArgument(argument instanceof List, "Argument %s of %s must be a List<OperatorNode<%s>>", idx, parent, operatorType.getName(), argument.getClass());
+ List<OperatorNode<?>> lst = (List<OperatorNode<?>>) argument;
+ for (OperatorNode<?> node : lst) {
+ Operator op = node.getOperator();
+ Preconditions.checkArgument(operatorType.isInstance(op), "Argument %s of %s must contain only OperatorNode<%s> (is: %s).", idx, parent, operatorType.getName(), op.getClass());
+ if (!operators.isEmpty()) {
+ Preconditions.checkArgument(operators.contains(op), "Argument %s of %s must contain only %s (is %s).", idx, parent, Joiner.on("|").join(operators), op);
+ }
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/OperatorTypeChecker.java b/container-search/src/main/java/com/yahoo/search/yql/OperatorTypeChecker.java
new file mode 100644
index 00000000000..8266f414fa7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/OperatorTypeChecker.java
@@ -0,0 +1,19 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+/**
+ * Check the type of a single argument.
+ */
+abstract class OperatorTypeChecker {
+
+ protected final Operator parent;
+ protected final int idx;
+
+ protected OperatorTypeChecker(Operator parent, int idx) {
+ this.parent = parent;
+ this.idx = idx;
+ }
+
+ public abstract void check(Object argument);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/OperatorVisitor.java b/container-search/src/main/java/com/yahoo/search/yql/OperatorVisitor.java
new file mode 100644
index 00000000000..73c3612c1c9
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/OperatorVisitor.java
@@ -0,0 +1,10 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+interface OperatorVisitor {
+
+ <T extends Operator> boolean enter(OperatorNode<T> node);
+
+ <T extends Operator> void exit(OperatorNode<T> node);
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ParserBase.java b/container-search/src/main/java/com/yahoo/search/yql/ParserBase.java
new file mode 100644
index 00000000000..af3418919e8
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ParserBase.java
@@ -0,0 +1,38 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.collect.Sets;
+
+import org.antlr.v4.runtime.Parser;
+import org.antlr.v4.runtime.TokenStream;
+import org.antlr.v4.runtime.tree.ParseTree;
+
+import java.util.Set;
+
+/**
+ * Provides semantic helper functions to Parser.
+ */
+abstract class ParserBase extends Parser {
+
+ private static String arrayRuleName = "array";
+ public ParserBase(TokenStream input) {
+ super(input);
+ }
+
+ private Set<String> arrayParameters = Sets.newHashSet();
+
+ public void registerParameter(String name, String typeName) {
+ if (typeName.equals(arrayRuleName)) {
+ arrayParameters.add(name);
+ }
+ }
+
+ public boolean isArrayParameter(ParseTree nameNode) {
+ String name = nameNode.getText();
+ if (name.startsWith("@")) {
+ name = name.substring(1);
+ }
+ return name != null && arrayParameters.contains(name);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ProgramCompileException.java b/container-search/src/main/java/com/yahoo/search/yql/ProgramCompileException.java
new file mode 100644
index 00000000000..592bd690d56
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ProgramCompileException.java
@@ -0,0 +1,38 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+class ProgramCompileException extends RuntimeException {
+
+ private Location sourceLocation;
+
+ public ProgramCompileException(String message) {
+ super(message);
+ }
+
+ public ProgramCompileException(String message, Object... args) {
+ super(formatMessage(message, args));
+ }
+
+ private static String formatMessage(String message, Object... args) {
+ return args == null ? message : String.format(message, args);
+ }
+
+ public ProgramCompileException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public ProgramCompileException(Throwable cause) {
+ super(cause);
+ }
+
+ public ProgramCompileException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
+ super(message, cause, enableSuppression, writableStackTrace);
+ }
+
+
+ public ProgramCompileException(Location sourceLocation, String message, Object... args) {
+ super(String.format("%s %s", sourceLocation != null ? sourceLocation : "", args == null ? message : String.format(message, args)));
+ this.sourceLocation = sourceLocation;
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ProgramParser.java b/container-search/src/main/java/com/yahoo/search/yql/ProgramParser.java
new file mode 100644
index 00000000000..a8d1bc43a4c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ProgramParser.java
@@ -0,0 +1,1549 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.yahoo.search.yql.yqlplusParser.AnnotationContext;
+import com.yahoo.search.yql.yqlplusParser.AnnotateExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.ArgumentContext;
+import com.yahoo.search.yql.yqlplusParser.ArgumentsContext;
+import com.yahoo.search.yql.yqlplusParser.ArrayLiteralContext;
+import com.yahoo.search.yql.yqlplusParser.ArrayTypeContext;
+import com.yahoo.search.yql.yqlplusParser.Call_sourceContext;
+import com.yahoo.search.yql.yqlplusParser.ConstantArrayContext;
+import com.yahoo.search.yql.yqlplusParser.ConstantExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.ConstantMapExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.ConstantPropertyNameAndValueContext;
+import com.yahoo.search.yql.yqlplusParser.Delete_statementContext;
+import com.yahoo.search.yql.yqlplusParser.DereferencedExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.EqualityExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.ExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.FallbackContext;
+import com.yahoo.search.yql.yqlplusParser.Field_defContext;
+import com.yahoo.search.yql.yqlplusParser.Field_names_specContext;
+import com.yahoo.search.yql.yqlplusParser.Field_values_group_specContext;
+import com.yahoo.search.yql.yqlplusParser.Field_values_specContext;
+import com.yahoo.search.yql.yqlplusParser.IdentContext;
+import com.yahoo.search.yql.yqlplusParser.Import_listContext;
+import com.yahoo.search.yql.yqlplusParser.Import_statementContext;
+import com.yahoo.search.yql.yqlplusParser.InNotInTargetContext;
+import com.yahoo.search.yql.yqlplusParser.Insert_sourceContext;
+import com.yahoo.search.yql.yqlplusParser.Insert_statementContext;
+import com.yahoo.search.yql.yqlplusParser.Insert_valuesContext;
+import com.yahoo.search.yql.yqlplusParser.JoinExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.Join_exprContext;
+import com.yahoo.search.yql.yqlplusParser.LimitContext;
+import com.yahoo.search.yql.yqlplusParser.Literal_elementContext;
+import com.yahoo.search.yql.yqlplusParser.Literal_listContext;
+import com.yahoo.search.yql.yqlplusParser.LogicalANDExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.LogicalORExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.MapExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.MapTypeContext;
+import com.yahoo.search.yql.yqlplusParser.Merge_componentContext;
+import com.yahoo.search.yql.yqlplusParser.Merge_statementContext;
+import com.yahoo.search.yql.yqlplusParser.ModuleIdContext;
+import com.yahoo.search.yql.yqlplusParser.ModuleNameContext;
+import com.yahoo.search.yql.yqlplusParser.MultiplicativeExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.Namespaced_nameContext;
+import com.yahoo.search.yql.yqlplusParser.Next_statementContext;
+import com.yahoo.search.yql.yqlplusParser.OffsetContext;
+import com.yahoo.search.yql.yqlplusParser.OrderbyContext;
+import com.yahoo.search.yql.yqlplusParser.Orderby_fieldContext;
+import com.yahoo.search.yql.yqlplusParser.Output_specContext;
+import com.yahoo.search.yql.yqlplusParser.Paged_clauseContext;
+import com.yahoo.search.yql.yqlplusParser.ParamsContext;
+import com.yahoo.search.yql.yqlplusParser.Pipeline_stepContext;
+import com.yahoo.search.yql.yqlplusParser.Procedure_argumentContext;
+import com.yahoo.search.yql.yqlplusParser.Program_arglistContext;
+import com.yahoo.search.yql.yqlplusParser.Project_specContext;
+import com.yahoo.search.yql.yqlplusParser.ProgramContext;
+import com.yahoo.search.yql.yqlplusParser.PropertyNameAndValueContext;
+import com.yahoo.search.yql.yqlplusParser.Query_statementContext;
+import com.yahoo.search.yql.yqlplusParser.RelationalExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.RelationalOpContext;
+import com.yahoo.search.yql.yqlplusParser.Returning_specContext;
+import com.yahoo.search.yql.yqlplusParser.Scalar_literalContext;
+import com.yahoo.search.yql.yqlplusParser.Select_source_joinContext;
+import com.yahoo.search.yql.yqlplusParser.Select_source_multiContext;
+import com.yahoo.search.yql.yqlplusParser.Select_statementContext;
+import com.yahoo.search.yql.yqlplusParser.Selectvar_statementContext;
+import com.yahoo.search.yql.yqlplusParser.Sequence_sourceContext;
+import com.yahoo.search.yql.yqlplusParser.Source_listContext;
+import com.yahoo.search.yql.yqlplusParser.Source_specContext;
+import com.yahoo.search.yql.yqlplusParser.Source_statementContext;
+import com.yahoo.search.yql.yqlplusParser.StatementContext;
+import com.yahoo.search.yql.yqlplusParser.TimeoutContext;
+import com.yahoo.search.yql.yqlplusParser.TypenameContext;
+import com.yahoo.search.yql.yqlplusParser.UnaryExpressionContext;
+import com.yahoo.search.yql.yqlplusParser.Update_statementContext;
+import com.yahoo.search.yql.yqlplusParser.Update_valuesContext;
+import com.yahoo.search.yql.yqlplusParser.ViewContext;
+import com.yahoo.search.yql.yqlplusParser.WhereContext;
+
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.CharStream;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.antlr.v4.runtime.Token;
+import org.antlr.v4.runtime.TokenStream;
+import org.antlr.v4.runtime.atn.PredictionMode;
+import org.antlr.v4.runtime.misc.NotNull;
+import org.antlr.v4.runtime.misc.Nullable;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.RuleNode;
+import org.antlr.v4.runtime.tree.TerminalNode;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Translate the ANTLR grammar into the logical representation.
+ */
+final class ProgramParser {
+
+ public yqlplusParser prepareParser(String programName, InputStream input) throws IOException {
+ return prepareParser(programName, new CaseInsensitiveInputStream(input));
+ }
+
+ public yqlplusParser prepareParser(String programName, String input) throws IOException {
+ return prepareParser(programName, new CaseInsensitiveInputStream(input));
+ }
+
+ public yqlplusParser prepareParser(File file) throws IOException {
+ return prepareParser(file.getAbsoluteFile().toString(), new CaseInsensitiveFileStream(file.getAbsolutePath()));
+ }
+
+
+ private yqlplusParser prepareParser(final String programName, CharStream input) {
+ yqlplusLexer lex = new yqlplusLexer(input);
+ lex.addErrorListener(new BaseErrorListener() {
+ @Override
+ public void syntaxError(@NotNull Recognizer<?, ?> recognizer,
+ @Nullable Object offendingSymbol,
+ int line,
+ int charPositionInLine,
+ @NotNull String msg,
+ @Nullable RecognitionException e)
+ {
+ throw new ProgramCompileException(new Location(programName, line, charPositionInLine), msg);
+ }
+
+ });
+ TokenStream tokens = new CommonTokenStream(lex);
+ yqlplusParser parser = new yqlplusParser(tokens);
+ parser.addErrorListener(new BaseErrorListener() {
+ @Override
+ public void syntaxError(@NotNull Recognizer<?, ?> recognizer,
+ @Nullable Object offendingSymbol,
+ int line,
+ int charPositionInLine,
+ @NotNull String msg,
+ @Nullable RecognitionException e)
+ {
+ throw new ProgramCompileException(new Location(programName, line, charPositionInLine), msg);
+ }
+
+ });
+ parser.getInterpreter().setPredictionMode(PredictionMode.SLL);
+ return parser;
+ }
+
+ private ProgramContext parseProgram(yqlplusParser parser) throws RecognitionException {
+ try {
+ return parser.program();
+ } catch (RecognitionException e) {
+ //Retry parsing using full LL mode
+ parser.reset();
+ parser.getInterpreter().setPredictionMode(PredictionMode.LL);
+ return parser.program();
+ }
+ }
+
+ public OperatorNode<StatementOperator> parse(String programName, InputStream program) throws IOException, RecognitionException {
+ yqlplusParser parser = prepareParser(programName, program);
+ return convertProgram(parseProgram(parser), parser, programName);
+ }
+
+ public OperatorNode<StatementOperator> parse(String programName, String program) throws IOException, RecognitionException {
+ yqlplusParser parser = prepareParser(programName, program);
+ return convertProgram(parseProgram(parser), parser, programName);
+ }
+
+ public OperatorNode<StatementOperator> parse(File input) throws IOException, RecognitionException {
+ yqlplusParser parser = prepareParser(input);
+ return convertProgram(parseProgram(parser), parser, input.getAbsoluteFile().toString());
+ }
+
+ public OperatorNode<ExpressionOperator> parseExpression(String input) throws IOException, RecognitionException {
+ return convertExpr(prepareParser("<expression>", input).expression(false).getRuleContext(), new Scope());
+ }
+
+ public OperatorNode<ExpressionOperator> parseExpression(String input, Set<String> visibleAliases) throws IOException, RecognitionException {
+ Scope scope = new Scope();
+ final Location loc = new Location("<expression>", -1, -1);
+ for (String alias : visibleAliases) {
+ scope.defineDataSource(loc, alias);
+ }
+ return convertExpr(prepareParser("<expression>", input).expression(false).getRuleContext(), scope);
+ }
+
+ private Location toLocation(Scope scope, ParseTree node) {
+ Token start;
+ if (node instanceof ParserRuleContext) {
+ start = ((ParserRuleContext)node).start;
+ } else if (node instanceof TerminalNode) {
+ start = ((TerminalNode)node).getSymbol();
+ } else {
+ throw new ProgramCompileException("Location is not available for type " + node.getClass());
+ }
+ Location location = new Location(scope != null? scope.programName: "<string>", start.getLine(), start.getCharPositionInLine());
+ return location;
+ }
+
+ private List<String> readName(Namespaced_nameContext node) {
+ List<String> path = Lists.newArrayList();
+ for (ParseTree elt:node.children) {
+ if (!(getParseTreeIndex(elt) == yqlplusParser.DOT)) {
+ path.add(elt.getText());
+ }
+ }
+ return path;
+ }
+
+ static class Binding {
+ private final List<String> binding;
+
+ Binding(String moduleName, String exportName) {
+ this.binding = ImmutableList.of(moduleName, exportName);
+ }
+
+ Binding(String moduleName) {
+ this.binding = ImmutableList.of(moduleName);
+ }
+
+ Binding(List<String> binding) {
+ this.binding = binding;
+ }
+
+ public List<String> toPath() {
+ return binding;
+ }
+
+ public List<String> toPathWith(List<String> rest) {
+ return ImmutableList.copyOf(Iterables.concat(toPath(), rest));
+ }
+ }
+
+ static class Scope {
+ final Scope root;
+ final Scope parent;
+ Set<String> cursors = ImmutableSet.of();
+ Set<String> variables = ImmutableSet.of();
+ Set<String> views = Sets.newHashSet();
+ Map<String, Binding> bindings = Maps.newHashMap();
+ final yqlplusParser parser;
+ final String programName;
+
+ Scope() {
+ this.parser = null;
+ this.programName = null;
+ this.root = this;
+ this.parent = null;
+ }
+
+ Scope(yqlplusParser parser, String programName) {
+ this.parser = parser;
+ this.programName = programName;
+ this.root = this;
+ this.parent = null;
+ }
+
+ Scope(Scope root, Scope parent) {
+ this.root = root;
+ this.parent = parent;
+ this.parser = parent.parser;
+ this.programName = parent.programName;
+ }
+
+ public yqlplusParser getParser() {
+ return parser;
+ }
+
+ public String getProgramName() {
+ return programName;
+ }
+
+ public Set<String> getCursors() {
+ return cursors;
+ }
+
+
+ boolean isBound(String name) {
+ // bindings live only in the 'root' node
+ return root.bindings.containsKey(name);
+ }
+
+ public Binding getBinding(String name) {
+ return root.bindings.get(name);
+ }
+
+ public List<String> resolvePath(List<String> path) {
+ if (path.size() < 1 || !isBound(path.get(0))) {
+ return path;
+ } else {
+ return getBinding(path.get(0)).toPathWith(path.subList(1, path.size()));
+ }
+ }
+
+ boolean isCursor(String name) {
+ return cursors.contains(name) || (parent != null && parent.isCursor(name));
+ }
+
+ boolean isVariable(String name) {
+ return variables.contains(name) || (parent != null && parent.isVariable(name));
+ }
+
+ public void bindModule(Location loc, List<String> binding, String symbolName) {
+ if (isBound(symbolName)) {
+ throw new ProgramCompileException(loc, "Name '%s' is already used.", symbolName);
+ }
+ root.bindings.put(symbolName, new Binding(binding));
+ }
+
+ public void bindModuleSymbol(Location loc, List<String> moduleName, String exportName, String symbolName) {
+ ImmutableList.Builder<String> builder = ImmutableList.builder();
+ builder.addAll(moduleName);
+ builder.add(exportName);
+ bindModule(loc, builder.build(), symbolName);
+ }
+
+ public void defineDataSource(Location loc, String name) {
+ if (isCursor(name)) {
+ throw new ProgramCompileException(loc, "Alias '%s' is already used.", name);
+ }
+ if (cursors.isEmpty()) {
+ cursors = Sets.newHashSet();
+ }
+ cursors.add(name);
+ }
+
+ public void defineVariable(Location loc, String name) {
+ if (isVariable(name)) {
+ throw new ProgramCompileException(loc, "Variable/argument '%s' is already used.", name);
+ }
+ if (variables.isEmpty()) {
+ variables = Sets.newHashSet();
+ }
+ variables.add(name);
+
+ }
+
+ public void defineView(Location loc, String text) {
+ if (this != root) {
+ throw new IllegalStateException("Views MUST be defined in 'root' scope only");
+ }
+ if (views.contains(text)) {
+ throw new ProgramCompileException(loc, "View '%s' already defined", text);
+ }
+ views.add(text);
+ }
+
+ Scope child() {
+ return new Scope(root, this);
+ }
+
+ Scope getRoot() {
+ return root;
+ }
+ }
+
+ private OperatorNode<SequenceOperator> convertSelectOrInsertOrUpdateOrDelete(ParseTree node, Scope scopeParent) {
+
+ Preconditions.checkArgument(node instanceof Select_statementContext || node instanceof Insert_statementContext ||
+ node instanceof Update_statementContext || node instanceof Delete_statementContext);
+
+ // SELECT^ select_field_spec select_source where? orderby? limit? offset? timeout? fallback?
+ // select is the only place to define where/orderby/limit/offset and joins
+ Scope scope = scopeParent.child();
+ ProjectionBuilder proj = null;
+ OperatorNode<SequenceOperator> source = null;
+ OperatorNode<ExpressionOperator> filter = null;
+ List<OperatorNode<SortOperator>> orderby = null;
+ OperatorNode<ExpressionOperator> offset = null;
+ OperatorNode<ExpressionOperator> limit = null;
+ OperatorNode<ExpressionOperator> timeout = null;
+ OperatorNode<SequenceOperator> fallback = null;
+ OperatorNode<SequenceOperator> insertValues = null;
+ OperatorNode<ExpressionOperator> updateValues = null;
+
+ ParseTree sourceNode;
+
+ if (node instanceof Select_statementContext ) {
+ sourceNode = node.getChild(2) != null ? node.getChild(2).getChild(0):null;
+ } else {
+ sourceNode = node.getChild(1);
+ }
+
+ if (sourceNode != null) {
+ switch (getParseTreeIndex(sourceNode)) {
+ // ALL_SOURCE and MULTI_SOURCE are how FROM SOURCES
+ // *|source_name,... are parsed
+ // They can't be used directly with the JOIN syntax at this time
+ case yqlplusParser.RULE_select_source_all: {
+ Location location = toLocation(scope, sourceNode.getChild(2));
+ source = OperatorNode.create(location, SequenceOperator.ALL);
+ source.putAnnotation("alias", "row");
+ scope.defineDataSource(location, "row");
+ }
+ break;
+ case yqlplusParser.RULE_select_source_multi:
+ Source_listContext multiSourceContext = ((Select_source_multiContext) sourceNode).source_list();
+ source = readMultiSource(scope, multiSourceContext);
+ source.putAnnotation("alias", "row");
+ scope.defineDataSource(toLocation(scope, multiSourceContext), "row");
+ break;
+ case yqlplusParser.RULE_select_source_join:
+ source = convertSource((ParserRuleContext) sourceNode.getChild(1), scope);
+ List<Join_exprContext> joinContexts = ((Select_source_joinContext)sourceNode).join_expr();
+ for (Join_exprContext joinContext:joinContexts) {
+ source = convertJoin(joinContext, source, scope);
+ }
+ break;
+ case yqlplusParser.RULE_insert_source:
+ Insert_sourceContext insertSourceContext = (Insert_sourceContext) sourceNode;
+ source = convertSource((ParserRuleContext)insertSourceContext.getChild(1), scope);
+ break;
+ case yqlplusParser.RULE_delete_source:
+ source = convertSource((ParserRuleContext)sourceNode.getChild(1), scope);
+ break;
+ case yqlplusParser.RULE_update_source:
+ source = convertSource((ParserRuleContext)sourceNode.getChild(0), scope);
+ break;
+ }
+ } else {
+ source = OperatorNode.create(SequenceOperator.EMPTY);
+ }
+
+ for (int i = 1; i < node.getChildCount(); ++i) {
+ ParseTree child = node.getChild(i);
+ switch (getParseTreeIndex(child)) {
+ case yqlplusParser.RULE_select_field_spec:
+ if (getParseTreeIndex(child.getChild(0)) == yqlplusParser.RULE_project_spec) {
+ proj = readProjection(((Project_specContext) child.getChild(0)).field_def(), scope);
+ }
+ break;
+ case yqlplusParser.RULE_returning_spec:
+ proj = readProjection(((Returning_specContext) child).select_field_spec().project_spec().field_def(), scope);
+ break;
+ case yqlplusParser.RULE_where:
+ filter = convertExpr(((WhereContext) child).expression(), scope);
+ break;
+ case yqlplusParser.RULE_orderby:
+ // OrderbyContext orderby()
+ List<Orderby_fieldContext> orderFieds = ((OrderbyContext) child)
+ .orderby_fields().orderby_field();
+ orderby = Lists.newArrayListWithExpectedSize(orderFieds.size());
+ for (int j = 0; j < orderFieds.size(); ++j) {
+ orderby.add(convertSortKey(orderFieds.get(j), scope));
+ }
+ break;
+ case yqlplusParser.RULE_limit:
+ limit = convertExpr(((LimitContext) child).fixed_or_parameter(), scope);
+ break;
+ case yqlplusParser.RULE_offset:
+ offset = convertExpr(((OffsetContext) child).fixed_or_parameter(), scope);
+ break;
+ case yqlplusParser.RULE_timeout:
+ timeout = convertExpr(((TimeoutContext) child).fixed_or_parameter(), scope);
+ break;
+ case yqlplusParser.RULE_fallback:
+ fallback = convertQuery(((FallbackContext) child).select_statement(), scope);
+ break;
+ case yqlplusParser.RULE_insert_values:
+ if (child.getChild(0) instanceof yqlplusParser.Query_statementContext) {
+ insertValues = convertQuery(child.getChild(0).getChild(0), scope);
+ } else {
+ insertValues = readBatchValues(((Insert_valuesContext) child).field_names_spec(), ((Insert_valuesContext)child).field_values_group_spec(), scope);
+ }
+ break;
+ case yqlplusParser.RULE_update_values:
+ if (getParseTreeIndex(child.getChild(0)) == yqlplusParser.RULE_field_def) {
+ updateValues = readValues(((Update_valuesContext)child).field_def(), scope);
+ } else {
+ updateValues = readValues((Field_names_specContext)child.getChild(0), (Field_values_specContext)child.getChild(2), scope);
+ }
+ break;
+ }
+ }
+ // now assemble the logical plan
+ OperatorNode<SequenceOperator> result = source;
+ // filter
+ if (filter != null) {
+ result = OperatorNode.create(SequenceOperator.FILTER, result, filter);
+ }
+ // insert values
+ if (insertValues != null) {
+ result = OperatorNode.create(SequenceOperator.INSERT, result, insertValues);
+ }
+ // update
+ if (updateValues != null) {
+ if (filter != null) {
+ result = OperatorNode.create(SequenceOperator.UPDATE, source, updateValues, filter);
+ } else {
+ result = OperatorNode.create(SequenceOperator.UPDATE_ALL, source, updateValues);
+ }
+ }
+ // delete
+ if (getParseTreeIndex(node) == yqlplusParser.RULE_delete_statement) {
+ if (filter != null) {
+ result = OperatorNode.create(SequenceOperator.DELETE, source, filter);
+ } else {
+ result = OperatorNode.create(SequenceOperator.DELETE_ALL, source);
+ }
+ }
+ // then sort (or project and sort)
+ boolean projectBeforeSort = false;
+ if (orderby != null) {
+ if (proj != null) {
+ for (OperatorNode<SortOperator> sortKey : orderby) {
+ OperatorNode<ExpressionOperator> sortExpression = sortKey.getArgument(0);
+ List<OperatorNode<ExpressionOperator>> sortReadFields = getReadFieldExpressions(sortExpression);
+ for (OperatorNode<ExpressionOperator> sortReadField : sortReadFields) {
+ String sortKeyField = sortReadField.getArgument(1);
+ if (proj.isAlias(sortKeyField)) {
+ // TODO: Add support for "mixed" case
+ projectBeforeSort = true;
+ break;
+ }
+ }
+ }
+ }
+ if (projectBeforeSort) {
+ result = OperatorNode.create(SequenceOperator.SORT, proj.make(result), orderby);
+ } else {
+ result = OperatorNode.create(SequenceOperator.SORT, result, orderby);
+ }
+ }
+ // then offset/limit (must be done after sorting!)
+ if (offset != null && limit != null) {
+ result = OperatorNode.create(SequenceOperator.SLICE, result, offset, limit);
+ } else if (offset != null) {
+ result = OperatorNode.create(SequenceOperator.OFFSET, result, offset);
+ } else if (limit != null) {
+ result = OperatorNode.create(SequenceOperator.LIMIT, result, limit);
+ }
+ // finally, project (if not already)
+ if (proj != null && !projectBeforeSort) {
+ result = proj.make(result);
+ }
+ if (timeout != null) {
+ result = OperatorNode.create(SequenceOperator.TIMEOUT, result, timeout);
+ }
+ // if there's a fallback, emit a fallback node
+ if (fallback != null) {
+ result = OperatorNode.create(SequenceOperator.FALLBACK, result, fallback);
+ }
+ return result;
+ }
+
+ private OperatorNode<ExpressionOperator> readValues(List<Field_defContext> fieldDefs, Scope scope) {
+ List<String> fieldNames;
+ List<OperatorNode<ExpressionOperator>> fieldValues;
+ int numPairs = fieldDefs.size();
+ fieldNames = Lists.newArrayListWithExpectedSize(numPairs);
+ fieldValues = Lists.newArrayListWithExpectedSize(numPairs);
+ for (int j = 0; j < numPairs; j++) {
+ ParseTree startNode = fieldDefs.get(j);
+ while(startNode.getChildCount() < 3) {
+ startNode = startNode.getChild(0);
+ }
+ fieldNames.add((String) convertExpr(startNode.getChild(0), scope).getArgument(1));
+ fieldValues.add(convertExpr(startNode.getChild(2), scope));
+ }
+ return OperatorNode.create(ExpressionOperator.MAP, fieldNames, fieldValues);
+ }
+
+ private OperatorNode<SequenceOperator> readMultiSource(Scope scope, Source_listContext multiSource) {
+ List<List<String>> sourceNameList = Lists.newArrayList();
+ List<Namespaced_nameContext> nameSpaces = multiSource.namespaced_name();
+ for(Namespaced_nameContext node : nameSpaces) {
+ List<String> name = readName(node);
+ sourceNameList.add(name);
+ }
+ return OperatorNode.create(toLocation(scope, multiSource), SequenceOperator.MULTISOURCE, sourceNameList);
+ }
+// pipeline_step
+// : namespaced_name arguments[false]?
+// ;
+ private OperatorNode<SequenceOperator> convertPipe(Query_statementContext queryStatementContext, List<Pipeline_stepContext> nodes, Scope scope) {
+ OperatorNode<SequenceOperator> result = convertQuery(queryStatementContext.getChild(0), scope.getRoot());
+ for (Pipeline_stepContext step:nodes) {
+ if (getParseTreeIndex(step.getChild(0)) == yqlplusParser.RULE_vespa_grouping) {
+ result = OperatorNode.create(SequenceOperator.PIPE, result, ImmutableList.<String>of(),
+ ImmutableList.of(convertExpr(step.getChild(0), scope)));
+ } else {
+ List<String> name = readName(step.namespaced_name());
+ List<OperatorNode<ExpressionOperator>> args = ImmutableList.of();
+ //LPAREN (argument[$in_select] (COMMA argument[$in_select])*) RPAREN
+ if (step.getChildCount() > 1) {
+ ArgumentsContext arguments = step.arguments();
+ if (arguments.getChildCount() > 2) {
+ List<ArgumentContext> argumentContextList = arguments.argument();
+ args = Lists.newArrayListWithExpectedSize(argumentContextList.size());
+ for (ArgumentContext argumentContext: argumentContextList) {
+ args.add(convertExpr(argumentContext.expression(), scope.getRoot()));
+
+ }
+ }
+ }
+ result = OperatorNode.create(SequenceOperator.PIPE, result, scope.resolvePath(name), args);
+ }
+ }
+ return result;
+ }
+
+ private OperatorNode<SequenceOperator> convertMerge(List<Merge_componentContext> mergeComponentList, Scope scope) {
+ Preconditions.checkArgument(mergeComponentList != null);
+ List<OperatorNode<SequenceOperator>> sources = Lists.newArrayListWithExpectedSize(mergeComponentList.size());
+ for (Merge_componentContext mergeComponent:mergeComponentList) {
+ Select_statementContext selectContext = mergeComponent.select_statement();
+ Source_statementContext sourceContext = mergeComponent.source_statement();
+ if (selectContext != null) {
+ sources.add(convertQuery(selectContext, scope.getRoot()));
+ } else {
+ sources.add(convertQuery(sourceContext, scope.getRoot()));
+ }
+ }
+ return OperatorNode.create(SequenceOperator.MERGE, sources);
+ }
+
+ private OperatorNode<SequenceOperator> convertQuery(ParseTree node, Scope scope) {
+ if (node instanceof Select_statementContext
+ || node instanceof Insert_statementContext
+ || node instanceof Update_statementContext
+ || node instanceof Delete_statementContext) {
+ return convertSelectOrInsertOrUpdateOrDelete(node, scope.getRoot());
+ } else if (node instanceof Source_statementContext) { //for pipe
+ Source_statementContext sourceStatementContext = (Source_statementContext)node;
+ return convertPipe(sourceStatementContext.query_statement(), sourceStatementContext.pipeline_step(), scope);
+ } else if (node instanceof Merge_statementContext) {
+ return convertMerge(((Merge_statementContext)node).merge_component(), scope);
+ } else {
+ throw new IllegalArgumentException("Unexpected argument type to convertQueryStatement: " + node.toStringTree());
+ }
+
+ }
+
+ private OperatorNode<SequenceOperator> convertJoin(Join_exprContext node, OperatorNode<SequenceOperator> left, Scope scope) {
+ Source_specContext sourceSpec = node.source_spec();
+ OperatorNode<SequenceOperator> right = convertSource(sourceSpec, scope);
+ JoinExpressionContext joinContext = node.joinExpression();
+ OperatorNode<ExpressionOperator> joinExpression = readBinOp(ExpressionOperator.valueOf("EQ"), joinContext.getChild(0), joinContext.getChild(2), scope);
+ if (joinExpression.getOperator() != ExpressionOperator.EQ) {
+ throw new ProgramCompileException(joinExpression.getLocation(), "Unexpected join expression type: %s (expected EQ)", joinExpression.getOperator());
+ }
+ return OperatorNode.create(toLocation(scope, sourceSpec), node.join_spec().LEFT() != null ? SequenceOperator.LEFT_JOIN : SequenceOperator.JOIN, left, right, joinExpression);
+ }
+
+ private String assignAlias(String alias, ParserRuleContext node, Scope scope) {
+ if (alias == null) {
+ alias = "source";
+ }
+
+ if (node != null && node instanceof yqlplusParser.Alias_defContext) {
+ //alias_def : (AS? ID);
+ ParseTree idChild = node;
+ if (node.getChildCount() > 1) {
+ idChild = node.getChild(1);
+ }
+ alias = idChild.getText();
+ if (scope.isCursor(alias)) {
+ throw new ProgramCompileException(toLocation(scope, idChild), "Source alias '%s' is already used", alias);
+ }
+ scope.defineDataSource(toLocation(scope, idChild), alias);
+ return alias;
+ } else {
+ String candidate = alias;
+ int c = 0;
+ while (scope.isCursor(candidate)) {
+ candidate = alias + (++c);
+ }
+ scope.defineDataSource(null, candidate);
+ return alias;
+ }
+ }
+
+ private OperatorNode<SequenceOperator> convertSource(ParserRuleContext sourceSpecNode, Scope scope) {
+
+ // DataSources
+ String alias;
+ OperatorNode<SequenceOperator> result;
+ ParserRuleContext dataSourceNode = sourceSpecNode;
+ ParserRuleContext aliasContext = null;
+ //data_source
+ //: call_source
+ //| LPAREN source_statement RPAREN
+ //| sequence_source
+ //;
+ if (sourceSpecNode instanceof Source_specContext) {
+ dataSourceNode = (ParserRuleContext)sourceSpecNode.getChild(0);
+ if (sourceSpecNode.getChildCount() == 2) {
+ aliasContext = (ParserRuleContext)sourceSpecNode.getChild(1);
+ }
+ if (dataSourceNode.getChild(0) instanceof Call_sourceContext ||
+ dataSourceNode.getChild(0) instanceof Sequence_sourceContext) {
+ dataSourceNode = (ParserRuleContext)dataSourceNode.getChild(0);
+ } else { //source_statement
+ dataSourceNode = (ParserRuleContext)dataSourceNode.getChild(1);
+ }
+ }
+ switch (getParseTreeIndex(dataSourceNode)) {
+ case yqlplusParser.RULE_write_data_source:
+ case yqlplusParser.RULE_call_source: {
+ List<String> names = readName((Namespaced_nameContext)dataSourceNode.getChild(Namespaced_nameContext.class, 0));
+ alias = assignAlias(names.get(names.size() - 1), aliasContext, scope);
+ List<OperatorNode<ExpressionOperator>> arguments = ImmutableList.of();
+ ArgumentsContext argumentsContext = dataSourceNode.getRuleContext(ArgumentsContext.class,0);
+ if ( argumentsContext != null) {
+ List<ArgumentContext> argumentContexts = argumentsContext.argument();
+ arguments = Lists.newArrayListWithExpectedSize(argumentContexts.size());
+ for (ArgumentContext argumentContext:argumentContexts) {
+ arguments.add(convertExpr(argumentContext, scope));
+ }
+ }
+ if (names.size() == 1 && scope.isVariable(names.get(0))) {
+ String ident = names.get(0);
+ if (arguments.size() > 0) {
+ throw new ProgramCompileException(toLocation(scope, argumentsContext), "Invalid call-with-arguments on local source '%s'", ident);
+ }
+ result = OperatorNode.create(toLocation(scope, dataSourceNode), SequenceOperator.EVALUATE, OperatorNode.create(toLocation(scope, dataSourceNode), ExpressionOperator.VARREF, ident));
+ } else {
+ result = OperatorNode.create(toLocation(scope, dataSourceNode), SequenceOperator.SCAN, scope.resolvePath(names), arguments);
+ }
+ break;
+ }
+ case yqlplusParser.RULE_sequence_source: {
+ IdentContext identContext = dataSourceNode.getRuleContext(IdentContext.class,0);
+ String ident = identContext.getText();
+ if (!scope.isVariable(ident)) {
+ throw new ProgramCompileException(toLocation(scope, identContext), "Unknown variable reference '%s'", ident);
+ }
+ alias = assignAlias(ident, aliasContext, scope);
+ result = OperatorNode.create(toLocation(scope, dataSourceNode), SequenceOperator.EVALUATE, OperatorNode.create(toLocation(scope, dataSourceNode), ExpressionOperator.VARREF, ident));
+ break;
+ }
+ case yqlplusParser.RULE_source_statement: {
+ alias = assignAlias(null, dataSourceNode, scope);
+ result = convertQuery(dataSourceNode, scope);
+ break;
+ }
+ default:
+ throw new IllegalArgumentException("Unexpected argument type to convertSource: " + dataSourceNode.getText());
+ }
+ result.putAnnotation("alias", alias);
+ return result;
+ }
+
+ private OperatorNode<TypeOperator> decodeType(Scope scope, TypenameContext type) {
+
+ TypeOperator op;
+ ParseTree typeNode = type.getChild(0);
+ switch (getParseTreeIndex(typeNode)) {
+ case yqlplusParser.TYPE_BOOLEAN:
+ op = TypeOperator.BOOLEAN;
+ break;
+ case yqlplusParser.TYPE_BYTE:
+ op = TypeOperator.BYTE;
+ break;
+ case yqlplusParser.TYPE_DOUBLE:
+ op = TypeOperator.DOUBLE;
+ break;
+ case yqlplusParser.TYPE_INT16:
+ op = TypeOperator.INT16;
+ break;
+ case yqlplusParser.TYPE_INT32:
+ op = TypeOperator.INT32;
+ break;
+ case yqlplusParser.TYPE_INT64:
+ op = TypeOperator.INT64;
+ break;
+ case yqlplusParser.TYPE_STRING:
+ op = TypeOperator.STRING;
+ break;
+ case yqlplusParser.TYPE_TIMESTAMP:
+ op = TypeOperator.TIMESTAMP;
+ break;
+ case yqlplusParser.RULE_arrayType:
+ return OperatorNode.create(toLocation(scope, typeNode), TypeOperator.ARRAY, decodeType(scope, ((ArrayTypeContext)typeNode).getChild(TypenameContext.class, 0)));
+ case yqlplusParser.RULE_mapType:
+ return OperatorNode.create(toLocation(scope, typeNode), TypeOperator.MAP, decodeType(scope, ((MapTypeContext)typeNode).getChild(TypenameContext.class, 0)));
+ default:
+ throw new ProgramCompileException("Unknown type " + typeNode.getText());
+ }
+ return OperatorNode.create(toLocation(scope, typeNode), op);
+ }
+
+ private List<String> createBindingName(ParseTree node) {
+ if (node instanceof ModuleNameContext) {
+ if (((ModuleNameContext)node).namespaced_name() != null) {
+ return readName(((ModuleNameContext)node).namespaced_name());
+ } else if (((ModuleNameContext)node).literalString() != null) {
+ return ImmutableList.of(((ModuleNameContext)node).literalString().STRING().getText());
+ }
+ } else if (node instanceof ModuleIdContext) {
+ return ImmutableList.of(node.getText());
+ }
+ throw new ProgramCompileException("Wrong context");
+ }
+
+ private OperatorNode<StatementOperator> convertProgram(
+ ParserRuleContext program, yqlplusParser parser, String programName) {
+ Scope scope = new Scope(parser, programName);
+ List<OperatorNode<StatementOperator>> stmts = Lists.newArrayList();
+ int output = 0;
+ for (ParseTree node : program.children) {
+ if (!(node instanceof ParserRuleContext)) {
+ continue;
+ }
+ ParserRuleContext ruleContext = (ParserRuleContext) node;
+ switch (ruleContext.getRuleIndex()) {
+ case yqlplusParser.RULE_params: {
+ // ^(ARGUMENT ident typeref expression?)
+ ParamsContext paramsContext = (ParamsContext) ruleContext;
+ Program_arglistContext program_arglistContext = paramsContext.program_arglist();
+ if (program_arglistContext != null) {
+ List<Procedure_argumentContext> argList = program_arglistContext.procedure_argument();
+ for (Procedure_argumentContext procedureArgumentContext : argList) {
+ String name = procedureArgumentContext.ident().getText();
+ OperatorNode<TypeOperator> type = decodeType(scope, procedureArgumentContext.getChild(TypenameContext.class, 0));
+ OperatorNode<ExpressionOperator> defaultValue = OperatorNode.create(ExpressionOperator.NULL);
+ if (procedureArgumentContext.expression() != null) {
+ defaultValue = convertExpr(procedureArgumentContext.expression(), scope);
+ }
+ scope.defineVariable(toLocation(scope, procedureArgumentContext), name);
+ stmts.add(OperatorNode.create(StatementOperator.ARGUMENT, name, type, defaultValue));
+ }
+ }
+ break;
+ }
+ case yqlplusParser.RULE_import_statement: {
+ Import_statementContext importContext = (Import_statementContext) ruleContext;
+ if (null == importContext.import_list()) {
+ List<String> name = createBindingName(node.getChild(1));
+ String target;
+ Location location = toLocation(scope, node.getChild(1));
+ if (node.getChildCount() == 2) {
+ target = name.get(0);
+ } else if (node.getChildCount() == 4) {
+ target = node.getChild(3).getText();
+ } else {
+ throw new ProgramCompileException("Unknown node count for IMPORT: " + node.toStringTree());
+ }
+ scope.bindModule(location, name, target);
+ } else {
+ // | FROM moduleName IMPORT import_list -> ^(IMPORT_FROM
+ // moduleName import_list+)
+ Import_listContext importListContext = importContext.import_list();
+ List<String> name = createBindingName(importContext.moduleName());
+ Location location = toLocation(scope, importContext.moduleName());
+ List<ModuleIdContext> moduleIds = importListContext.moduleId();
+ List<String> symbols = Lists.newArrayListWithExpectedSize(moduleIds.size());
+ for (ModuleIdContext cnode : moduleIds) {
+ symbols.add(cnode.ID().getText());
+ }
+ for (String sym : symbols) {
+ scope.bindModuleSymbol(location, name, sym, sym);
+ }
+ }
+ break;
+ }
+
+ // DDL
+ case yqlplusParser.RULE_ddl:
+ ruleContext = (ParserRuleContext)ruleContext.getChild(0);
+ case yqlplusParser.RULE_view: {
+ // view and projection expansion now has to be done by the
+ // execution engine
+ // since views/projections, in order to be useful, have to
+ // support being used from outside the same program
+ ViewContext viewContext = (ViewContext) ruleContext;
+ Location loc = toLocation(scope, viewContext);
+ scope.getRoot().defineView(loc, viewContext.ID().getText());
+ stmts.add(OperatorNode.create(loc, StatementOperator.DEFINE_VIEW, viewContext.ID().getText(), convertQuery(viewContext.source_statement(), scope.getRoot())));
+ break;
+ }
+ case yqlplusParser.RULE_statement: {
+ // ^(STATEMENT_QUERY source_statement paged_clause?
+ // output_spec?)
+ StatementContext statementContext = (StatementContext) ruleContext;
+ switch (getParseTreeIndex(ruleContext.getChild(0))) {
+ case yqlplusParser.RULE_selectvar_statement: {
+ // ^(STATEMENT_SELECTVAR ident source_statement)
+ Selectvar_statementContext selectVarContext = (Selectvar_statementContext) ruleContext.getChild(0);
+ String variable = selectVarContext.ident().getText();
+ OperatorNode<SequenceOperator> query = convertQuery(selectVarContext.source_statement(), scope);
+ Location location = toLocation(scope, selectVarContext.ident());
+ scope.defineVariable(location, variable);
+ stmts.add(OperatorNode.create(location, StatementOperator.EXECUTE, query, variable));
+ break;
+ }
+ case yqlplusParser.RULE_next_statement: {
+ // NEXT^ literalString OUTPUT! AS! ident
+ Next_statementContext nextStateContext = (Next_statementContext) ruleContext.getChild(0);
+ String continuationValue = StringUnescaper.unquote(nextStateContext.literalString().getText());
+ String variable = nextStateContext.ident().getText();
+ Location location = toLocation(scope, node);
+ OperatorNode<SequenceOperator> next = OperatorNode.create(location, SequenceOperator.NEXT, continuationValue);
+ stmts.add(OperatorNode.create(location, StatementOperator.EXECUTE, next, variable));
+ stmts.add(OperatorNode.create(location, StatementOperator.OUTPUT, variable));
+ scope.defineVariable(location, variable);
+ break;
+ }
+ case yqlplusParser.RULE_output_statement:
+ Source_statementContext source_statement = statementContext.output_statement().source_statement();
+ OperatorNode<SequenceOperator> query;
+ if (source_statement.getChildCount() == 1) {
+ query = convertQuery( source_statement.query_statement().getChild(0), scope);
+ } else {
+ query = convertQuery(source_statement, scope);
+ }
+ String variable = "result" + (++output);
+ boolean isCountVariable = false;
+ OperatorNode<ExpressionOperator> pageSize = null;
+ ParseTree outputStatement = node.getChild(0);
+ Location location = toLocation(scope, outputStatement);
+ for (int i = 1; i < outputStatement.getChildCount(); ++i) {
+ ParseTree child = outputStatement.getChild(i);
+ switch (getParseTreeIndex(child)) {
+ case yqlplusParser.RULE_paged_clause:
+ Paged_clauseContext pagedContext = (Paged_clauseContext) child;
+ pageSize = convertExpr(pagedContext.fixed_or_parameter(), scope);
+ break;
+ case yqlplusParser.RULE_output_spec:
+ Output_specContext outputSpecContext = (Output_specContext) child;
+ variable = outputSpecContext.ident().getText();
+ if (outputSpecContext.COUNT() != null) {
+ isCountVariable = true;
+ }
+ break;
+ default:
+ throw new ProgramCompileException( "Unknown statement attribute: " + child.toStringTree());
+ }
+ }
+ scope.defineVariable(location, variable);
+ if (pageSize != null) {
+ query = OperatorNode.create(SequenceOperator.PAGE, query, pageSize);
+ }
+ stmts.add(OperatorNode.create(location, StatementOperator.EXECUTE, query, variable));
+ stmts.add(OperatorNode.create(location, isCountVariable ? StatementOperator.COUNT:StatementOperator.OUTPUT, variable));
+ }
+ break;
+ }
+ default:
+ throw new ProgramCompileException("Unknown program element: " + node.getText());
+ }
+ }
+ // traverse the tree, find all of the namespaced calls not covered by
+ // imports so we can
+ // define "implicit" import statements for them (to make engine
+ // implementation easier)
+ return OperatorNode.create(StatementOperator.PROGRAM, stmts);
+ }
+
+ private OperatorNode<SortOperator> convertSortKey(Orderby_fieldContext node, Scope scope) {
+ TerminalNode descDef = node.DESC();
+ OperatorNode<ExpressionOperator> exprNode = convertExpr(node.expression(), scope);
+ if (descDef != null ) {
+ return OperatorNode.create(toLocation(scope, descDef), SortOperator.DESC, exprNode);
+ } else {
+ return OperatorNode.create(toLocation(scope, node), SortOperator.ASC, exprNode);
+ }
+ }
+
+ private ProjectionBuilder readProjection(List<Field_defContext> fieldDefs, Scope scope) {
+ if (null == fieldDefs)
+ throw new ProgramCompileException("Null fieldDefs");
+ ProjectionBuilder proj = new ProjectionBuilder();
+ for (Field_defContext rulenode : fieldDefs) {
+ // FIELD
+ // expression alias_def?
+ OperatorNode<ExpressionOperator> expr = convertExpr((ExpressionContext)rulenode.getChild(0), scope);
+
+ String aliasName = null;
+ if (rulenode.getChildCount() > 1) {
+ // ^(ALIAS ID)
+ aliasName = rulenode.alias_def().ID().getText();
+ }
+ proj.addField(aliasName, expr);
+ // no grammar for the other rule types at this time
+ }
+ return proj;
+ }
+
+ public static int getParseTreeIndex(ParseTree parseTree) {
+ if (parseTree instanceof TerminalNode) {
+ return ((TerminalNode)parseTree).getSymbol().getType();
+ } else {
+ return ((RuleNode)parseTree).getRuleContext().getRuleIndex();
+ }
+ }
+
+ public OperatorNode<ExpressionOperator> convertExpr(ParseTree parseTree,
+ Scope scope) {
+ switch (getParseTreeIndex(parseTree)) {
+ case yqlplusParser.RULE_vespa_grouping: {
+ ParseTree firstChild = parseTree.getChild(0);
+ if (getParseTreeIndex(firstChild) == yqlplusParser.RULE_annotation) {
+ ParseTree secondChild = parseTree.getChild(1);
+ OperatorNode<ExpressionOperator> annotation = convertExpr(((AnnotationContext) firstChild)
+ .constantMapExpression(), scope);
+ OperatorNode<ExpressionOperator> expr = OperatorNode.create(toLocation(scope, secondChild),
+ ExpressionOperator.VESPA_GROUPING, secondChild.getText());
+ List<String> names = (List<String>) annotation.getArgument(0);
+ List<OperatorNode<ExpressionOperator>> annotates = (List<OperatorNode<ExpressionOperator>>) annotation
+ .getArgument(1);
+ for (int i = 0; i < names.size(); ++i) {
+ expr.putAnnotation(names.get(i), readConstantExpression(annotates.get(i)));
+ }
+ return expr;
+ } else {
+ return OperatorNode.create(toLocation(scope, firstChild), ExpressionOperator.VESPA_GROUPING,
+ firstChild.getText());
+ }
+ }
+ case yqlplusParser.RULE_nullOperator:
+ return OperatorNode.create(ExpressionOperator.NULL);
+ case yqlplusParser.RULE_argument:
+ return convertExpr(parseTree.getChild(0), scope);
+ case yqlplusParser.RULE_fixed_or_parameter: {
+ ParseTree firstChild = parseTree.getChild(0);
+ if (getParseTreeIndex(firstChild) == yqlplusParser.INT) {
+ return OperatorNode.create(toLocation(scope, firstChild), ExpressionOperator.LITERAL, new Integer(firstChild.getText()));
+ } else {
+ return convertExpr(firstChild, scope);
+ }
+ }
+ case yqlplusParser.RULE_constantMapExpression: {
+ List<ConstantPropertyNameAndValueContext> propertyList = ((ConstantMapExpressionContext) parseTree).constantPropertyNameAndValue();
+ List<String> names = Lists.newArrayListWithExpectedSize(propertyList.size());
+ List<OperatorNode<ExpressionOperator>> exprs = Lists.newArrayListWithExpectedSize(propertyList.size());
+ for (ConstantPropertyNameAndValueContext child : propertyList) {
+ // : propertyName ':' expression[$expression::namespace] ->
+ // ^(PROPERTY propertyName expression)
+ names.add(StringUnescaper.unquote(child.getChild(0).getText()));
+ exprs.add(convertExpr(child.getChild(2), scope));
+ }
+ return OperatorNode.create(toLocation(scope, parseTree),ExpressionOperator.MAP, names, exprs);
+ }
+ case yqlplusParser.RULE_mapExpression: {
+ List<PropertyNameAndValueContext> propertyList = ((MapExpressionContext)parseTree).propertyNameAndValue();
+ List<String> names = Lists.newArrayListWithExpectedSize(propertyList.size());
+ List<OperatorNode<ExpressionOperator>> exprs = Lists.newArrayListWithCapacity(propertyList.size());
+ for (PropertyNameAndValueContext child : propertyList) {
+ // : propertyName ':' expression[$expression::namespace] ->
+ // ^(PROPERTY propertyName expression)
+ names.add(StringUnescaper.unquote(child.getChild(0).getText()));
+ exprs.add(convertExpr(child.getChild(2), scope));
+ }
+ return OperatorNode.create(toLocation(scope, parseTree),ExpressionOperator.MAP, names, exprs);
+ }
+ case yqlplusParser.RULE_constantArray: {
+ List<ConstantExpressionContext> expressionList = ((ConstantArrayContext)parseTree).constantExpression();
+ List<OperatorNode<ExpressionOperator>> values = Lists.newArrayListWithExpectedSize(expressionList.size());
+ for (ConstantExpressionContext expr : expressionList) {
+ values.add(convertExpr(expr, scope));
+ }
+ return OperatorNode.create(toLocation(scope, expressionList.isEmpty()? parseTree:expressionList.get(0)), ExpressionOperator.ARRAY, values);
+ }
+ case yqlplusParser.RULE_arrayLiteral: {
+ List<ExpressionContext> expressionList = ((ArrayLiteralContext) parseTree).expression();
+ List<OperatorNode<ExpressionOperator>> values = Lists.newArrayListWithExpectedSize(expressionList.size());
+ for (ExpressionContext expr : expressionList) {
+ values.add(convertExpr(expr, scope));
+ }
+ return OperatorNode.create(toLocation(scope, expressionList.isEmpty()? parseTree:expressionList.get(0)), ExpressionOperator.ARRAY, values);
+ }
+ //dereferencedExpression: primaryExpression(indexref[in_select]| propertyref)*
+ case yqlplusParser.RULE_dereferencedExpression: {
+ DereferencedExpressionContext dereferencedExpression = (DereferencedExpressionContext) parseTree;
+ Iterator<ParseTree> it = dereferencedExpression.children.iterator();
+ OperatorNode<ExpressionOperator> result = convertExpr(it.next(), scope);
+ while (it.hasNext()) {
+ ParseTree defTree = it.next();
+ if (getParseTreeIndex(defTree) == yqlplusParser.RULE_propertyref) {
+ //DOT nm=ID
+ result = OperatorNode.create(toLocation(scope, parseTree), ExpressionOperator.PROPREF, result, defTree.getChild(1).getText());
+ } else {
+ //indexref
+ result = OperatorNode.create(toLocation(scope, parseTree), ExpressionOperator.INDEX, result, convertExpr(defTree.getChild(1), scope));
+ }
+ }
+ return result;
+ }
+ case yqlplusParser.RULE_primaryExpression: {
+ // ^(CALL namespaced_name arguments)
+ ParseTree firstChild = parseTree.getChild(0);
+ switch (getParseTreeIndex(firstChild)) {
+ case yqlplusParser.RULE_fieldref: {
+ return convertExpr(firstChild, scope);
+ }
+ case yqlplusParser.RULE_callExpresion: {
+ List<ArgumentContext> args = ((ArgumentsContext) firstChild.getChild(1)).argument();
+ List<OperatorNode<ExpressionOperator>> arguments = Lists.newArrayListWithExpectedSize(args.size());
+ for (ArgumentContext argContext : args) {
+ arguments.add(convertExpr(argContext.expression(),scope));
+ }
+ return OperatorNode.create(toLocation(scope, parseTree), ExpressionOperator.CALL, scope.resolvePath(readName((Namespaced_nameContext) firstChild.getChild(0))), arguments);
+ }
+ // TODO add processing this is not implemented in V3
+ // case yqlplusParser.APPLY:
+
+ case yqlplusParser.RULE_parameter:
+ // external variable reference
+ return OperatorNode.create(toLocation(scope, firstChild), ExpressionOperator.VARREF, firstChild.getChild(1).getText());
+ case yqlplusParser.RULE_scalar_literal:
+ case yqlplusParser.RULE_arrayLiteral:
+ case yqlplusParser.RULE_mapExpression:
+ return convertExpr(firstChild, scope);
+ case yqlplusParser.LPAREN:
+ return convertExpr(parseTree.getChild(1), scope);
+ }
+ }
+ // TODO: Temporarily disable CAST - think through how types are named
+ // case yqlplusParser.CAST: {
+ //
+ // return new Cast()
+ // }
+ // return new CastExpression(payload);
+ case yqlplusParser.RULE_parameter: {
+ // external variable reference
+ ParserRuleContext parameterContext = (ParserRuleContext) parseTree;
+ IdentContext identContext = parameterContext.getRuleContext(IdentContext.class, 0);
+ return OperatorNode.create(toLocation(scope, identContext), ExpressionOperator.VARREF, identContext.getText());
+ }
+ case yqlplusParser.RULE_annotateExpression: {
+ //annotation logicalORExpression
+ AnnotationContext annotateExpressionContext = ((AnnotateExpressionContext)parseTree).annotation();
+ OperatorNode<ExpressionOperator> annotation = convertExpr(annotateExpressionContext.constantMapExpression(), scope);
+ OperatorNode<ExpressionOperator> expr = convertExpr(parseTree.getChild(1), scope);
+ List<String> names = (List<String>) annotation.getArgument(0);
+ List<OperatorNode<ExpressionOperator>> annotates = (List<OperatorNode<ExpressionOperator>>) annotation.getArgument(1);
+ for (int i = 0; i < names.size(); ++i) {
+ expr.putAnnotation(names.get(i), readConstantExpression(annotates.get(i)));
+ }
+ return expr;
+ }
+ case yqlplusParser.RULE_expression: {
+ return convertExpr(parseTree.getChild(0), scope);
+ }
+ case yqlplusParser.RULE_logicalANDExpression:
+ LogicalANDExpressionContext andExpressionContext = (LogicalANDExpressionContext) parseTree;
+ return readConjOp(ExpressionOperator.AND, andExpressionContext.equalityExpression(), scope);
+ case yqlplusParser.RULE_logicalORExpression: {
+ int childCount = parseTree.getChildCount();
+ LogicalORExpressionContext logicalORExpressionContext = (LogicalORExpressionContext) parseTree;
+ if (childCount > 1) {
+ return readConjOrOp(ExpressionOperator.OR, logicalORExpressionContext, scope);
+ } else {
+ List<EqualityExpressionContext> equalityExpressionList = ((LogicalANDExpressionContext) parseTree.getChild(0)).equalityExpression();
+ if (equalityExpressionList.size() > 1) {
+ return readConjOp(ExpressionOperator.AND, equalityExpressionList, scope);
+ } else {
+ return convertExpr(equalityExpressionList.get(0), scope);
+ }
+ }
+ }
+ case yqlplusParser.RULE_equalityExpression: {
+ EqualityExpressionContext equalityExpression = (EqualityExpressionContext) parseTree;
+ RelationalExpressionContext relationalExpressionContext = equalityExpression.relationalExpression(0);
+ OperatorNode<ExpressionOperator> expr = convertExpr(relationalExpressionContext, scope);
+ InNotInTargetContext inNotInTarget = equalityExpression.inNotInTarget();
+ int childCount = equalityExpression.getChildCount();
+ if (childCount == 1) {
+ return expr;
+ }
+ if (inNotInTarget != null) {
+ Literal_listContext literalListContext = inNotInTarget.literal_list();
+ boolean isIN = equalityExpression.IN() != null;
+ if (literalListContext == null) {
+ Select_statementContext selectStatementContext = inNotInTarget.select_statement();
+ OperatorNode<SequenceOperator> query = convertQuery(selectStatementContext, scope);
+ return OperatorNode.create(expr.getLocation(),isIN ? ExpressionOperator.IN_QUERY: ExpressionOperator.NOT_IN_QUERY, expr, query);
+ } else {
+ // we need to identify the type of the target; if it's a
+ // scalar we need to wrap it in a CREATE_ARRAY
+ // if it's already a CREATE ARRAY then it's fine, otherwise
+ // we need to know the variable type
+ // return readBinOp(node.getType() == yqlplusParser.IN ?
+ // ExpressionOperator.IN : ExpressionOperator.NOT_IN, node,
+ // scope);
+ return readBinOp(isIN ? ExpressionOperator.IN: ExpressionOperator.NOT_IN, equalityExpression.getChild(0), literalListContext, scope);
+ }
+
+ } else {
+ ParseTree firstChild = equalityExpression.getChild(1);
+ if (equalityExpression.getChildCount() == 2) {
+ switch (getParseTreeIndex(firstChild)) {
+ case yqlplusParser.IS_NULL:
+ return readUnOp(ExpressionOperator.IS_NULL, relationalExpressionContext, scope);
+ case yqlplusParser.IS_NOT_NULL:
+ return readUnOp(ExpressionOperator.IS_NOT_NULL, relationalExpressionContext, scope);
+ }
+ } else {
+ switch (getParseTreeIndex(firstChild.getChild(0))) {
+ case yqlplusParser.EQ:
+ return readBinOp(ExpressionOperator.EQ, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ case yqlplusParser.NEQ:
+ return readBinOp(ExpressionOperator.NEQ, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ case yqlplusParser.LIKE:
+ return readBinOp(ExpressionOperator.LIKE, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ case yqlplusParser.NOTLIKE:
+ return readBinOp(ExpressionOperator.NOT_LIKE, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ case yqlplusParser.MATCHES:
+ return readBinOp(ExpressionOperator.MATCHES, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ case yqlplusParser.NOTMATCHES:
+ return readBinOp(ExpressionOperator.NOT_MATCHES, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ case yqlplusParser.CONTAINS:
+ return readBinOp(ExpressionOperator.CONTAINS, equalityExpression.getChild(0), equalityExpression.getChild(2), scope);
+ }
+ }
+
+ }
+ break;
+ }
+ case yqlplusParser.RULE_relationalExpression: {
+ RelationalExpressionContext relationalExpressionContext = (RelationalExpressionContext) parseTree;
+ RelationalOpContext opContext = relationalExpressionContext.relationalOp();
+ if (opContext != null) {
+ switch (getParseTreeIndex(relationalExpressionContext.relationalOp().getChild(0))) {
+ case yqlplusParser.LT:
+ return readBinOp(ExpressionOperator.LT, parseTree, scope);
+ case yqlplusParser.LTEQ:
+ return readBinOp(ExpressionOperator.LTEQ, parseTree, scope);
+ case yqlplusParser.GT:
+ return readBinOp(ExpressionOperator.GT, parseTree, scope);
+ case yqlplusParser.GTEQ:
+ return readBinOp(ExpressionOperator.GTEQ, parseTree, scope);
+ }
+ } else {
+ return convertExpr(relationalExpressionContext.additiveExpression(0), scope);
+ }
+ }
+ break;
+ case yqlplusParser.RULE_additiveExpression:
+ case yqlplusParser.RULE_multiplicativeExpression: {
+ if (parseTree.getChildCount() > 1) {
+ String opStr = parseTree.getChild(1).getText();
+ switch (opStr) {
+ case "+":
+ return readBinOp(ExpressionOperator.ADD, parseTree, scope);
+ case "-":
+ return readBinOp(ExpressionOperator.SUB, parseTree, scope);
+ case "/":
+ return readBinOp(ExpressionOperator.DIV, parseTree, scope);
+ case "*":
+ return readBinOp(ExpressionOperator.MULT, parseTree, scope);
+ case "%":
+ return readBinOp(ExpressionOperator.MOD, parseTree, scope);
+ default:
+ if (parseTree.getChild(0) instanceof UnaryExpressionContext) {
+ return convertExpr(parseTree.getChild(0), scope);
+ } else {
+ throw new ProgramCompileException(toLocation(scope, parseTree), "Unknown expression type: " + parseTree.toStringTree());
+ }
+ }
+ } else {
+ if (parseTree.getChild(0) instanceof UnaryExpressionContext) {
+ return convertExpr(parseTree.getChild(0), scope);
+ } else if (parseTree.getChild(0) instanceof MultiplicativeExpressionContext) {
+ return convertExpr(parseTree.getChild(0), scope);
+ } else {
+ throw new ProgramCompileException(toLocation(scope, parseTree), "Unknown expression type: " + parseTree.getText());
+ }
+ }
+ }
+ case yqlplusParser.RULE_unaryExpression: {
+ if (1 == parseTree.getChildCount()) {
+ return convertExpr(parseTree.getChild(0), scope);
+ } else if (2 == parseTree.getChildCount()) {
+ if ("-".equals(parseTree.getChild(0).getText())) {
+ return readUnOp(ExpressionOperator.NEGATE, parseTree, scope);
+ } else if ("!".equals(parseTree.getChild(0).getText())) {
+ return readUnOp(ExpressionOperator.NOT, parseTree, scope);
+ }
+ throw new ProgramCompileException(toLocation(scope, parseTree),"Unknown unary operator " + parseTree.getText());
+ } else {
+ throw new ProgramCompileException(toLocation(scope, parseTree),"Unknown child count " + parseTree.getChildCount() + " of " + parseTree.getText());
+ }
+ }
+ case yqlplusParser.RULE_fieldref:
+ case yqlplusParser.RULE_joinDereferencedExpression: {
+ // all in-scope data sources should be defined in scope
+ // the 'first' field in a namespaced reference must be:
+ // - a field name if (and only if) there is exactly one data source
+ // in scope OR
+ // - an alias name, which will be followed by a field name
+ // ^(FIELDREF<FieldReference>[$expression::namespace]
+ // namespaced_name)
+ List<String> path = readName((Namespaced_nameContext) parseTree.getChild(0));
+ Location loc = toLocation(scope, parseTree.getChild(0));
+ String alias = path.get(0);
+ OperatorNode<ExpressionOperator> result = null;
+ int start = 0;
+ if (scope.isCursor(alias)) {
+ if (path.size() > 1) {
+ result = OperatorNode.create(loc, ExpressionOperator.READ_FIELD, alias, path.get(1));
+ start = 2;
+ } else {
+ result = OperatorNode.create(loc, ExpressionOperator.READ_RECORD, alias);
+ start = 1;
+ }
+ } else if (scope.isBound(alias)) {
+ return OperatorNode.create(loc, ExpressionOperator.READ_MODULE, scope.getBinding(alias).toPathWith(path.subList(1, path.size())));
+ } else if (scope.getCursors().size() == 1) {
+ alias = scope.getCursors().iterator().next();
+ result = OperatorNode.create(loc, ExpressionOperator.READ_FIELD, alias, path.get(0));
+ start = 1;
+ } else {
+ // ah ha, we can't end up with a 'loose' UDF call because it
+ // won't be a module or known alias
+ // so we need not support implicit imports for constants used in
+ // UDFs
+ throw new ProgramCompileException(loc, "Unknown field or alias '%s'", alias);
+ }
+ for (int idx = start; idx < path.size(); ++idx) {
+ result = OperatorNode.create(loc, ExpressionOperator.PROPREF, result, path.get(idx));
+ }
+ return result;
+ }
+ case yqlplusParser.RULE_scalar_literal:
+ return OperatorNode.create(toLocation(scope, parseTree), ExpressionOperator.LITERAL, convertLiteral((Scalar_literalContext) parseTree));
+ case yqlplusParser.RULE_insert_values:
+ return readValues((Insert_valuesContext) parseTree, scope);
+ case yqlplusParser.RULE_constantExpression:
+ return convertExpr(parseTree.getChild(0), scope);
+ case yqlplusParser.RULE_literal_list:
+ if (getParseTreeIndex(parseTree.getChild(1)) == yqlplusParser.RULE_array_parameter) {
+ return convertExpr(parseTree.getChild(1), scope);
+ } else {
+ List<Literal_elementContext> elements = ((Literal_listContext) parseTree).literal_element();
+ ParseTree firldElement = elements.get(0).getChild(0);
+ if (elements.size() == 1 && scope.getParser().isArrayParameter(firldElement)) {
+ return convertExpr(firldElement, scope);
+ } else {
+ List<OperatorNode<ExpressionOperator>> values = Lists.newArrayListWithExpectedSize(elements.size());
+ for (Literal_elementContext child : elements) {
+ values.add(convertExpr(child.getChild(0), scope));
+ }
+ return OperatorNode.create(toLocation(scope, elements.get(0)),ExpressionOperator.ARRAY, values);
+ }
+ }
+ }
+ throw new ProgramCompileException(toLocation(scope, parseTree),
+ "Unknown expression type: " + parseTree.getText());
+ }
+
+ public Object convertLiteral(Scalar_literalContext literal) {
+ int parseTreeIndex = getParseTreeIndex(literal.getChild(0));
+ String text = literal.getChild(0).getText();
+ switch(parseTreeIndex) {
+ case yqlplusParser.INT:
+ return new Integer(text);
+ case yqlplusParser.FLOAT:
+ return new Double(text);
+ case yqlplusParser.STRING:
+ return StringUnescaper.unquote(text);
+ case yqlplusParser.TRUE:
+ case yqlplusParser.FALSE:
+ return new Boolean(text);
+ case yqlplusParser.LONG_INT:
+ return Long.parseLong(text.substring(0, text.length()-1));
+ default:
+ throw new ProgramCompileException("Unknow literal type " + text);
+ }
+ }
+
+ private Object readConstantExpression(OperatorNode<ExpressionOperator> node) {
+ switch (node.getOperator()) {
+ case LITERAL:
+ return node.getArgument(0);
+ case MAP: {
+ ImmutableMap.Builder<String, Object> map = ImmutableMap.builder();
+ List<String> names = (List<String>) node.getArgument(0);
+ List<OperatorNode<ExpressionOperator>> exprs = (List<OperatorNode<ExpressionOperator>>) node.getArgument(1);
+ for (int i = 0; i < names.size(); ++i) {
+ map.put(names.get(i), readConstantExpression(exprs.get(i)));
+ }
+ return map.build();
+ }
+ case ARRAY: {
+ List<OperatorNode<ExpressionOperator>> exprs = (List<OperatorNode<ExpressionOperator>>) node.getArgument(0);
+ ImmutableList.Builder<Object> lst = ImmutableList.builder();
+ for (OperatorNode<ExpressionOperator> expr : exprs) {
+ lst.add(readConstantExpression(expr));
+ }
+ return lst.build();
+ }
+ default:
+ throw new ProgramCompileException(node.getLocation(), "Internal error: Unknown constant expression type: " + node.getOperator());
+ }
+ }
+
+ private OperatorNode<ExpressionOperator> readBinOp(ExpressionOperator op, ParseTree node, Scope scope) {
+ assert node.getChildCount() == 3;
+ return OperatorNode.create(op, convertExpr(node.getChild(0), scope), convertExpr(node.getChild(2), scope));
+ }
+
+ private OperatorNode<ExpressionOperator> readBinOp(ExpressionOperator op, ParseTree operand1, ParseTree operand2, Scope scope) {
+ return OperatorNode.create(op, convertExpr(operand1, scope), convertExpr(operand2, scope));
+ }
+
+ private OperatorNode<ExpressionOperator> readConjOp(ExpressionOperator op, List<EqualityExpressionContext> nodes, Scope scope) {
+ List<OperatorNode<ExpressionOperator>> arguments = Lists.newArrayListWithExpectedSize(nodes.size());
+ for (ParseTree child : nodes) {
+ arguments.add(convertExpr(child, scope));
+ }
+ return OperatorNode.create(op, arguments);
+ }
+
+ private OperatorNode<ExpressionOperator> readConjOrOp(ExpressionOperator op, LogicalORExpressionContext node, Scope scope) {
+ List<LogicalANDExpressionContext> andExpressionList = node.logicalANDExpression();
+ List<OperatorNode<ExpressionOperator>> arguments = Lists.newArrayListWithExpectedSize(andExpressionList.size());
+ for (LogicalANDExpressionContext child : andExpressionList) {
+ List<EqualityExpressionContext> equalities = child.equalityExpression();
+ if (equalities.size() == 1) {
+ arguments.add(convertExpr(equalities.get(0), scope));
+ } else {
+ List<OperatorNode<ExpressionOperator>> andArguments = Lists.newArrayListWithExpectedSize(equalities.size());
+ for (EqualityExpressionContext subTreeChild:equalities) {
+ andArguments.add(convertExpr(subTreeChild, scope));
+ }
+ arguments.add(OperatorNode.create(ExpressionOperator.AND, andArguments));
+ }
+
+ }
+ return OperatorNode.create(op, arguments);
+ }
+
+ // (IS_NULL | IS_NOT_NULL)
+ // unaryExpression
+ private OperatorNode<ExpressionOperator> readUnOp(ExpressionOperator op, ParseTree node, Scope scope) {
+ assert (node instanceof TerminalNode) || (node.getChildCount() == 1) || (node instanceof UnaryExpressionContext);
+ if (node instanceof TerminalNode) {
+ return OperatorNode.create(op, convertExpr(node, scope));
+ } else if (node.getChildCount() == 1) {
+ return OperatorNode.create(op, convertExpr(node.getChild(0), scope));
+ } else {
+ return OperatorNode.create(op, convertExpr(node.getChild(1), scope));
+ }
+ }
+
+ private OperatorNode<ExpressionOperator> readValues(Field_names_specContext nameDefs, Field_values_specContext values, Scope scope) {
+ List<Field_defContext> fieldDefs = nameDefs.field_def();
+ List<ExpressionContext> valueDefs = values.expression();
+ assert fieldDefs.size() == valueDefs.size();
+ List<String> fieldNames;
+ List<OperatorNode<ExpressionOperator>> fieldValues;
+ int numPairs = fieldDefs.size();
+ fieldNames = Lists.newArrayListWithExpectedSize(numPairs);
+ fieldValues = Lists.newArrayListWithExpectedSize(numPairs);
+ for (int i = 0; i < numPairs; i++) {
+ fieldNames.add((String) convertExpr(fieldDefs.get(i).expression(), scope).getArgument(1));
+ fieldValues.add(convertExpr(valueDefs.get(i), scope));
+ }
+ return OperatorNode.create(ExpressionOperator.MAP, fieldNames, fieldValues);
+ }
+
+ private OperatorNode<ExpressionOperator> readValues(ParserRuleContext node, Scope scope) {
+ List<String> fieldNames;
+ List<OperatorNode<ExpressionOperator>> fieldValues;
+ if (node.getRuleIndex() == yqlplusParser.RULE_field_def) {
+ Field_defContext fieldDefContext = (Field_defContext)node;
+ //TODO double check
+ fieldNames = Lists.newArrayListWithExpectedSize(node.getChildCount());
+ fieldValues = Lists.newArrayListWithExpectedSize(node.getChildCount());
+ for (int i = 0; i < node.getChildCount(); i++) {
+ fieldNames.add((String) convertExpr(node.getChild(i).getChild(0).getChild(0), scope).getArgument(1));
+ fieldValues.add(convertExpr(node.getChild(i).getChild(0).getChild(1), scope));
+ }
+ } else {
+ assert node.getChildCount() % 2 == 0;
+ int numPairs = node.getChildCount() / 2;
+ fieldNames = Lists.newArrayListWithExpectedSize(numPairs);
+ fieldValues = Lists.newArrayListWithExpectedSize(numPairs);
+ for (int i = 0; i < numPairs; i++) {
+ fieldNames.add((String) convertExpr(node.getChild(i).getChild(0), scope).getArgument(1));
+ fieldValues.add(convertExpr(node.getChild(numPairs + i), scope));
+ }
+ }
+ return OperatorNode.create(ExpressionOperator.MAP, fieldNames, fieldValues);
+ }
+
+ /*
+ * Converts node list
+ *
+ * a_name, b_name, c_name, a_value_1, b_value_1, c_value_1, a_value_2, b_value_2, c_value2, a_value_3, b_value_3, c_value_3
+ *
+ * into corresponding constant sequence:
+ *
+ * [ { a_name : a_value_1, b_name : b_value_1, c_name : c_value_1 }, ... ]
+ *
+ */
+ private OperatorNode<SequenceOperator> readBatchValues(Field_names_specContext nameDefs, List<Field_values_group_specContext> valueGroups, Scope scope) {
+ List<Field_defContext> nameContexts = nameDefs.field_def();
+ List<String> fieldNames = Lists.newArrayList();
+ for (Field_defContext nameContext:nameContexts) {
+ fieldNames.add((String) convertExpr(nameContext.getChild(0), scope).getArgument(1));
+ }
+ List<OperatorNode> records = Lists.newArrayList();
+ for (Field_values_group_specContext valueGorup:valueGroups) {
+ List<ExpressionContext> expressionList = valueGorup.expression();
+ List<OperatorNode<ExpressionOperator>> fieldValues = Lists.newArrayListWithExpectedSize(expressionList.size());
+ for (ExpressionContext expressionContext:expressionList) {
+ fieldValues.add(convertExpr(expressionContext, scope));
+ }
+ records.add(OperatorNode.create(ExpressionOperator.MAP, fieldNames, fieldValues));
+ }
+ // Return constant sequence of records with the given name/values
+ return OperatorNode.create(SequenceOperator.EVALUATE, OperatorNode.create(ExpressionOperator.ARRAY, records));
+ }
+
+ /*
+ * Scans the given node for READ_FIELD expressions.
+ *
+ * TODO: Search recursively and consider additional operators
+ *
+ * @param in the node to scan
+ * @return list of READ_FIELD expressions
+ */
+ private List<OperatorNode<ExpressionOperator>> getReadFieldExpressions(OperatorNode<ExpressionOperator> in) {
+ List<OperatorNode<ExpressionOperator>> readFieldList = Lists.newArrayList();
+ switch (in.getOperator()) {
+ case READ_FIELD:
+ readFieldList.add(in);
+ break;
+ case CALL:
+ List<OperatorNode<ExpressionOperator>> callArgs = in.getArgument(1);
+ for (OperatorNode<ExpressionOperator> callArg : callArgs) {
+ if (callArg.getOperator() == ExpressionOperator.READ_FIELD) {
+ readFieldList.add(callArg);
+ }
+ }
+ break;
+ }
+ return readFieldList;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ProjectOperator.java b/container-search/src/main/java/com/yahoo/search/yql/ProjectOperator.java
new file mode 100644
index 00000000000..16ecc4c4077
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ProjectOperator.java
@@ -0,0 +1,34 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Predicate;
+
+/**
+ * Represents a projection command which affects the output record.
+ */
+enum ProjectOperator implements Operator {
+
+ FIELD(ExpressionOperator.class, String.class), // FIELD expr name
+ RECORD(ExpressionOperator.class, String.class), // RECORD expr name
+ MERGE_RECORD(String.class); // MERGE_RECORD name (alias of record to merge)
+
+ private final ArgumentsTypeChecker checker;
+
+ public static Predicate<OperatorNode<? extends Operator>> IS = new Predicate<OperatorNode<? extends Operator>>() {
+ @Override
+ public boolean apply(OperatorNode<? extends Operator> input) {
+ return input.getOperator() instanceof ProjectOperator;
+ }
+ };
+
+ private ProjectOperator(Object... types) {
+ checker = TypeCheckers.make(this, types);
+ }
+
+
+ @Override
+ public void checkArguments(Object... args) {
+ checker.check(args);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/ProjectionBuilder.java b/container-search/src/main/java/com/yahoo/search/yql/ProjectionBuilder.java
new file mode 100644
index 00000000000..109d1cd654b
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/ProjectionBuilder.java
@@ -0,0 +1,73 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import java.util.Map;
+import java.util.Set;
+
+class ProjectionBuilder {
+
+ private Map<String, OperatorNode<ExpressionOperator>> fields = Maps.newLinkedHashMap();
+ private Set<String> aliasNames = Sets.newHashSet();
+
+ public void addField(String name, OperatorNode<ExpressionOperator> expr) {
+ String aliasName = name;
+ if (name == null) {
+ name = assignName(expr);
+ }
+ if (fields.containsKey(name)) {
+ throw new ProgramCompileException(expr.getLocation(), "Field alias '%s' already defined", name);
+ }
+ fields.put(name, expr);
+ if (aliasName != null) {
+ // Store use
+ aliasNames.add(aliasName);
+ }
+ }
+
+ public boolean isAlias(String name) {
+ return aliasNames.contains(name);
+ }
+
+ private String assignName(OperatorNode<ExpressionOperator> expr) {
+ String baseName = "expr";
+ switch (expr.getOperator()) {
+ case PROPREF:
+ baseName = (String) expr.getArgument(1);
+ break;
+ case READ_RECORD:
+ baseName = (String) expr.getArgument(0);
+ break;
+ case READ_FIELD:
+ baseName = (String) expr.getArgument(1);
+ break;
+ case VARREF:
+ baseName = (String) expr.getArgument(0);
+ break;
+ // fall through, leaving baseName alone
+ }
+ int c = 0;
+ String candidate = baseName;
+ while (fields.containsKey(candidate)) {
+ candidate = baseName + (++c);
+ }
+ return candidate;
+ }
+
+ public OperatorNode<SequenceOperator> make(OperatorNode<SequenceOperator> target) {
+ ImmutableList.Builder<OperatorNode<ProjectOperator>> lst = ImmutableList.builder();
+ for (Map.Entry<String, OperatorNode<ExpressionOperator>> e : fields.entrySet()) {
+ if (e.getKey().startsWith("*")) {
+ lst.add(OperatorNode.create(ProjectOperator.MERGE_RECORD, e.getValue().getArgument(0)));
+ } else if (e.getValue().getOperator() == ExpressionOperator.READ_RECORD) {
+ lst.add(OperatorNode.create(ProjectOperator.RECORD, e.getValue(), e.getKey()));
+ } else {
+ lst.add(OperatorNode.create(ProjectOperator.FIELD, e.getValue(), e.getKey()));
+ }
+ }
+ return OperatorNode.create(SequenceOperator.PROJECT, target, lst.build());
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/SequenceOperator.java b/container-search/src/main/java/com/yahoo/search/yql/SequenceOperator.java
new file mode 100644
index 00000000000..65d1e039e10
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/SequenceOperator.java
@@ -0,0 +1,68 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Predicate;
+import com.google.inject.TypeLiteral;
+
+import java.util.List;
+
+/**
+ * Logical sequence operators represent a logical description of a "source" (query against data stores + pipes), representing
+ * a source_expression in the grammar.
+ */
+enum SequenceOperator implements Operator {
+
+ SCAN(TypeCheckers.LIST_OF_STRING, TypeCheckers.EXPRS), // scan a named data source (with optional arguments)
+ /**
+ * INSERT(target-sequence, input-records)
+ */
+ INSERT(SequenceOperator.class, SequenceOperator.class),
+ UPDATE(SequenceOperator.class, ExpressionOperator.MAP, ExpressionOperator.class),
+ UPDATE_ALL(SequenceOperator.class, ExpressionOperator.MAP),
+ DELETE(SequenceOperator.class, ExpressionOperator.class),
+ DELETE_ALL(SequenceOperator.class),
+ EMPTY(), // emits a single, empty row
+ // evaluate the given expression and use the result as a sequence
+ EVALUATE(ExpressionOperator.class),
+ NEXT(String.class),
+
+ PROJECT(SequenceOperator.class, new TypeLiteral<List<OperatorNode<ProjectOperator>>>() {
+ }), // transform a sequence into a new schema
+ FILTER(SequenceOperator.class, ExpressionOperator.class), // filter a sequence by an expression
+ SORT(SequenceOperator.class, new TypeLiteral<List<OperatorNode<SortOperator>>>() {
+ }), // sort a sequence
+ PIPE(SequenceOperator.class, TypeCheckers.LIST_OF_STRING, TypeCheckers.EXPRS), // pipe from one source through a named transformation
+ LIMIT(SequenceOperator.class, ExpressionOperator.class),
+ OFFSET(SequenceOperator.class, ExpressionOperator.class),
+ SLICE(SequenceOperator.class, ExpressionOperator.class, ExpressionOperator.class),
+ MERGE(TypeCheckers.SEQUENCES),
+ JOIN(SequenceOperator.class, SequenceOperator.class, ExpressionOperator.class), // combine two (or more, in the case of MERGE) sequences to produce a new sequence
+ LEFT_JOIN(SequenceOperator.class, SequenceOperator.class, ExpressionOperator.class),
+
+ FALLBACK(SequenceOperator.class, SequenceOperator.class),
+
+ TIMEOUT(SequenceOperator.class, ExpressionOperator.class),
+ PAGE(SequenceOperator.class, ExpressionOperator.class),
+ ALL(),
+ MULTISOURCE(TypeCheckers.LIST_OF_LIST_OF_STRING);
+
+ private final ArgumentsTypeChecker checker;
+
+ public static Predicate<OperatorNode<? extends Operator>> IS = new Predicate<OperatorNode<? extends Operator>>() {
+ @Override
+ public boolean apply(OperatorNode<? extends Operator> input) {
+ return input.getOperator() instanceof SequenceOperator;
+ }
+ };
+
+ private SequenceOperator(Object... types) {
+ checker = TypeCheckers.make(this, types);
+ }
+
+
+ @Override
+ public void checkArguments(Object... args) {
+ checker.check(args);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/SortOperator.java b/container-search/src/main/java/com/yahoo/search/yql/SortOperator.java
new file mode 100644
index 00000000000..db03f787524
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/SortOperator.java
@@ -0,0 +1,33 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Predicate;
+
+/**
+ * Represents a sort argument. ORDER BY foo; → (ASC foo)
+ */
+enum SortOperator implements Operator {
+
+ ASC(ExpressionOperator.class),
+ DESC(ExpressionOperator.class);
+
+ private final ArgumentsTypeChecker checker;
+
+ public static Predicate<OperatorNode<? extends Operator>> IS = new Predicate<OperatorNode<? extends Operator>>() {
+ @Override
+ public boolean apply(OperatorNode<? extends Operator> input) {
+ return input.getOperator() instanceof SortOperator;
+ }
+ };
+
+ private SortOperator(Object... types) {
+ checker = TypeCheckers.make(this, types);
+ }
+
+
+ @Override
+ public void checkArguments(Object... args) {
+ checker.check(args);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/StatementOperator.java b/container-search/src/main/java/com/yahoo/search/yql/StatementOperator.java
new file mode 100644
index 00000000000..f25212e1098
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/StatementOperator.java
@@ -0,0 +1,41 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Predicate;
+import com.google.inject.TypeLiteral;
+
+import java.util.List;
+
+/**
+ * Represents program statements.
+ */
+enum StatementOperator implements Operator {
+
+ PROGRAM(new TypeLiteral<List<OperatorNode<StatementOperator>>>() {
+ }),
+ ARGUMENT(String.class, TypeOperator.class, ExpressionOperator.class),
+ DEFINE_VIEW(String.class, SequenceOperator.class),
+ EXECUTE(SequenceOperator.class, String.class),
+ OUTPUT(String.class),
+ COUNT(String.class);
+
+ private final ArgumentsTypeChecker checker;
+
+ public static Predicate<OperatorNode<? extends Operator>> IS = new Predicate<OperatorNode<? extends Operator>>() {
+ @Override
+ public boolean apply(OperatorNode<? extends Operator> input) {
+ return input.getOperator() instanceof StatementOperator;
+ }
+ };
+
+ private StatementOperator(Object... types) {
+ checker = TypeCheckers.make(this, types);
+ }
+
+
+ @Override
+ public void checkArguments(Object... args) {
+ checker.check(args);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/StringUnescaper.java b/container-search/src/main/java/com/yahoo/search/yql/StringUnescaper.java
new file mode 100644
index 00000000000..76d81429ab3
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/StringUnescaper.java
@@ -0,0 +1,123 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+class StringUnescaper {
+
+ private static boolean lookaheadOctal(String v, int point) {
+ return point < v.length() && "01234567".indexOf(v.charAt(point)) != -1;
+ }
+
+ public static String unquote(String token) {
+ if (null == token || !(token.startsWith("'") && token.endsWith("'") || token.startsWith("\"") && token.endsWith("\""))) {
+ return token;
+ }
+ // remove quotes from around string and unescape it
+ String value = token.substring(1, token.length() - 1);
+ // first quickly check to see if \ is present -- if not then there's no escaping and we're done
+ int idx = value.indexOf('\\');
+ if (idx == -1) {
+ return value;
+ }
+ // the output string will be no bigger than the input string, since escapes add characters
+ StringBuilder result = new StringBuilder(value.length());
+ int start = 0;
+ while (idx != -1) {
+ result.append(value.subSequence(start, idx));
+ start = idx + 1;
+ switch (value.charAt(start)) {
+ case 'b':
+ result.append('\b');
+ ++start;
+ break;
+ case 't':
+ result.append('\t');
+ ++start;
+ break;
+ case 'n':
+ result.append('\n');
+ ++start;
+ break;
+ case 'f':
+ result.append('\f');
+ ++start;
+ break;
+ case 'r':
+ result.append('\r');
+ ++start;
+ break;
+ case '"':
+ result.append('"');
+ ++start;
+ break;
+ case '\'':
+ result.append('\'');
+ ++start;
+ break;
+ case '\\':
+ result.append('\\');
+ ++start;
+ break;
+ case '/':
+ result.append('/');
+ ++start;
+ break;
+ case 'u':
+ // hex hex hex hex
+ ++start;
+ result.append((char) Integer.parseInt(value.substring(start, start + 4), 16));
+ start += 4;
+ break;
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ // octal escape
+ // 1, 2, or 3 bytes
+ // peek ahead
+ if (lookaheadOctal(value, start + 1) && lookaheadOctal(value, start + 2)) {
+ result.append((char) Integer.parseInt(value.substring(start, start + 3), 8));
+ start += 3;
+ } else if (lookaheadOctal(value, start + 1)) {
+ result.append((char) Integer.parseInt(value.substring(start, start + 2), 8));
+ start += 2;
+ } else {
+ result.append((char) Integer.parseInt(value.substring(start, start + 1), 8));
+ start += 1;
+ }
+ break;
+ default:
+ // the lexer should be ensuring there are no malformed escapes here, so we'll just blow up
+ throw new IllegalArgumentException("Unknown escape sequence in token: " + token);
+ }
+ idx = value.indexOf('\\', start);
+ }
+ result.append(value.subSequence(start, value.length()));
+ return result.toString();
+ }
+
+ public static String escape(String value) {
+ int idx = value.indexOf('\'');
+ if (idx == -1) {
+ return "\'" + value + "\'";
+
+ }
+ StringBuilder result = new StringBuilder(value.length() + 5);
+ result.append("'");
+ // right now we only escape ' on output
+ int start = 0;
+ while (idx != -1) {
+ result.append(value.subSequence(start, idx));
+ start = idx + 1;
+ result.append("\\'");
+ idx = value.indexOf('\\', start);
+ }
+ result.append(value.subSequence(start, value.length()));
+ result.append("'");
+ return result.toString();
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/TypeCheckers.java b/container-search/src/main/java/com/yahoo/search/yql/TypeCheckers.java
new file mode 100644
index 00000000000..32aca6d5708
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/TypeCheckers.java
@@ -0,0 +1,108 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.inject.TypeLiteral;
+
+import java.lang.reflect.ParameterizedType;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+final class TypeCheckers {
+
+ public static final TypeLiteral<List<String>> LIST_OF_STRING = new TypeLiteral<List<String>>() {
+ };
+ public static final TypeLiteral<List<List<String>>> LIST_OF_LIST_OF_STRING = new TypeLiteral<List<List<String>>>() {
+ };
+ public static final TypeLiteral<List<OperatorNode<SequenceOperator>>> SEQUENCES = new TypeLiteral<List<OperatorNode<SequenceOperator>>>() {
+ };
+ public static final TypeLiteral<List<OperatorNode<ExpressionOperator>>> EXPRS = new TypeLiteral<List<OperatorNode<ExpressionOperator>>>() {
+ };
+ public static final TypeLiteral<List<List<OperatorNode<ExpressionOperator>>>> LIST_OF_EXPRS = new TypeLiteral<List<List<OperatorNode<ExpressionOperator>>>>() {
+ };
+ public static final ImmutableSet<Class<?>> LITERAL_TYPES = ImmutableSet.<Class<?>>builder()
+ .add(String.class)
+ .add(Integer.class)
+ .add(Double.class)
+ .add(Boolean.class)
+ .add(Float.class)
+ .add(Byte.class)
+ .add(Long.class)
+ .add(List.class)
+ .add(Map.class)
+ .build();
+
+ private TypeCheckers() {
+ }
+
+ public static ArgumentsTypeChecker make(Operator target, Object... types) {
+ // Class<?> extends Operator -> NodeTypeChecker
+ if (types == null) {
+ types = new Object[0];
+ }
+ List<OperatorTypeChecker> checkers = Lists.newArrayListWithCapacity(types.length);
+ for (int i = 0; i < types.length; ++i) {
+ checkers.add(createChecker(target, i, types[i]));
+ }
+ return new ArgumentsTypeChecker(target, checkers);
+ }
+
+ // this is festooned with instance checkes before all the casting
+ @SuppressWarnings("unchecked")
+ private static OperatorTypeChecker createChecker(Operator parent, int idx, Object value) {
+ if (value instanceof TypeLiteral) {
+ TypeLiteral<?> lit = (TypeLiteral<?>) value;
+ Class<?> raw = lit.getRawType();
+ if (List.class.isAssignableFrom(raw)) {
+ Preconditions.checkArgument(lit.getType() instanceof ParameterizedType, "TypeLiteral without a ParameterizedType for List");
+ ParameterizedType type = (ParameterizedType) lit.getType();
+ TypeLiteral<?> arg = TypeLiteral.get(type.getActualTypeArguments()[0]);
+ if (OperatorNode.class.isAssignableFrom(arg.getRawType())) {
+ Preconditions.checkArgument(arg.getType() instanceof ParameterizedType, "Type spec must be List<OperatorNode<?>>");
+ Class<? extends Operator> optype = (Class<? extends Operator>) TypeLiteral.get(((ParameterizedType) arg.getType()).getActualTypeArguments()[0]).getRawType();
+ return new OperatorNodeListTypeChecker(parent, idx, optype, ImmutableSet.<Operator>of());
+ } else {
+ return new JavaListTypeChecker(parent, idx, arg.getRawType());
+ }
+ }
+ throw new IllegalArgumentException("don't know how to handle TypeLiteral " + value);
+ }
+ if (value instanceof Class) {
+ Class<?> clazz = (Class<?>) value;
+ if (Operator.class.isAssignableFrom(clazz)) {
+ return new NodeTypeChecker(parent, idx, (Class<? extends Operator>) clazz, ImmutableSet.<Operator>of());
+ } else {
+ return new JavaTypeChecker(parent, idx, clazz);
+ }
+ } else if (value instanceof Operator) {
+ Operator operator = (Operator) value;
+ Class<? extends Operator> clazz = operator.getClass();
+ Set<? extends Operator> allowed;
+ if (Enum.class.isInstance(value)) {
+ Class<? extends Enum> enumClazz = (Class<? extends Enum>) clazz;
+ allowed = (Set<? extends Operator>) EnumSet.of(enumClazz.cast(value));
+ } else {
+ allowed = ImmutableSet.of(operator);
+ }
+ return new NodeTypeChecker(parent, idx, clazz, allowed);
+ } else if (value instanceof EnumSet) {
+ EnumSet<?> v = (EnumSet<?>) value;
+ Enum elt = Iterables.get(v, 0);
+ if (elt instanceof Operator) {
+ Class<? extends Operator> opclass = (Class<? extends Operator>) elt.getClass();
+ Set<? extends Operator> allowed = (Set<? extends Operator>) v;
+ return new NodeTypeChecker(parent, idx, opclass, allowed);
+ }
+ } else if (value instanceof Set) {
+ // Set<Class<?>>
+ return new JavaUnionTypeChecker(parent, idx, (Set<Class<?>>) value);
+ }
+ throw new IllegalArgumentException("I don't know how to create a checker from " + value);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/TypeOperator.java b/container-search/src/main/java/com/yahoo/search/yql/TypeOperator.java
new file mode 100644
index 00000000000..01b1f88cc5e
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/TypeOperator.java
@@ -0,0 +1,37 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.google.common.base.Predicate;
+
+enum TypeOperator implements Operator {
+
+ BYTE,
+ INT16,
+ INT32,
+ INT64,
+ STRING,
+ DOUBLE,
+ TIMESTAMP,
+ BOOLEAN,
+ ARRAY(TypeOperator.class),
+ MAP(TypeOperator.class);
+
+ private final ArgumentsTypeChecker checker;
+
+ public static Predicate<OperatorNode<? extends Operator>> IS = new Predicate<OperatorNode<? extends Operator>>() {
+ @Override
+ public boolean apply(OperatorNode<? extends Operator> input) {
+ return input.getOperator() instanceof TypeOperator;
+ }
+ };
+
+ TypeOperator(Object... types) {
+ checker = TypeCheckers.make(this, types);
+ }
+
+ @Override
+ public void checkArguments(Object... args) {
+ checker.check(args);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/VespaGroupingStep.java b/container-search/src/main/java/com/yahoo/search/yql/VespaGroupingStep.java
new file mode 100644
index 00000000000..520728dc231
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/VespaGroupingStep.java
@@ -0,0 +1,29 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.grouping.request.GroupingOperation;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen Hult</a>
+ */
+public class VespaGroupingStep {
+
+ private final GroupingOperation operation;
+ private final List<Continuation> continuations = new ArrayList<>();
+
+ public VespaGroupingStep(GroupingOperation operation) {
+ this.operation = operation;
+ }
+
+ public GroupingOperation getOperation() {
+ return operation;
+ }
+
+ public List<Continuation> continuations() {
+ return continuations;
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/VespaSerializer.java b/container-search/src/main/java/com/yahoo/search/yql/VespaSerializer.java
new file mode 100644
index 00000000000..397225a087c
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/VespaSerializer.java
@@ -0,0 +1,1381 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import static com.yahoo.search.yql.YqlParser.ACCENT_DROP;
+import static com.yahoo.search.yql.YqlParser.ALTERNATIVES;
+import static com.yahoo.search.yql.YqlParser.AND_SEGMENTING;
+import static com.yahoo.search.yql.YqlParser.BOUNDS;
+import static com.yahoo.search.yql.YqlParser.BOUNDS_LEFT_OPEN;
+import static com.yahoo.search.yql.YqlParser.BOUNDS_OPEN;
+import static com.yahoo.search.yql.YqlParser.BOUNDS_RIGHT_OPEN;
+import static com.yahoo.search.yql.YqlParser.CONNECTION_ID;
+import static com.yahoo.search.yql.YqlParser.CONNECTION_WEIGHT;
+import static com.yahoo.search.yql.YqlParser.CONNECTIVITY;
+import static com.yahoo.search.yql.YqlParser.DISTANCE;
+import static com.yahoo.search.yql.YqlParser.DOT_PRODUCT;
+import static com.yahoo.search.yql.YqlParser.EQUIV;
+import static com.yahoo.search.yql.YqlParser.FILTER;
+import static com.yahoo.search.yql.YqlParser.HIT_LIMIT;
+import static com.yahoo.search.yql.YqlParser.IMPLICIT_TRANSFORMS;
+import static com.yahoo.search.yql.YqlParser.LABEL;
+import static com.yahoo.search.yql.YqlParser.NEAR;
+import static com.yahoo.search.yql.YqlParser.NORMALIZE_CASE;
+import static com.yahoo.search.yql.YqlParser.ONEAR;
+import static com.yahoo.search.yql.YqlParser.ORIGIN;
+import static com.yahoo.search.yql.YqlParser.ORIGIN_LENGTH;
+import static com.yahoo.search.yql.YqlParser.ORIGIN_OFFSET;
+import static com.yahoo.search.yql.YqlParser.ORIGIN_ORIGINAL;
+import static com.yahoo.search.yql.YqlParser.PHRASE;
+import static com.yahoo.search.yql.YqlParser.PREFIX;
+import static com.yahoo.search.yql.YqlParser.RANGE;
+import static com.yahoo.search.yql.YqlParser.RANK;
+import static com.yahoo.search.yql.YqlParser.RANKED;
+import static com.yahoo.search.yql.YqlParser.SCORE_THRESHOLD;
+import static com.yahoo.search.yql.YqlParser.SIGNIFICANCE;
+import static com.yahoo.search.yql.YqlParser.STEM;
+import static com.yahoo.search.yql.YqlParser.SUBSTRING;
+import static com.yahoo.search.yql.YqlParser.SUFFIX;
+import static com.yahoo.search.yql.YqlParser.TARGET_NUM_HITS;
+import static com.yahoo.search.yql.YqlParser.THRESHOLD_BOOST_FACTOR;
+import static com.yahoo.search.yql.YqlParser.UNIQUE_ID;
+import static com.yahoo.search.yql.YqlParser.USE_POSITION_DATA;
+import static com.yahoo.search.yql.YqlParser.WAND;
+import static com.yahoo.search.yql.YqlParser.WEAK_AND;
+import static com.yahoo.search.yql.YqlParser.WEIGHT;
+import static com.yahoo.search.yql.YqlParser.WEIGHTED_SET;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.AndSegmentItem;
+import com.yahoo.prelude.query.DotProductItem;
+import com.yahoo.prelude.query.EquivItem;
+import com.yahoo.prelude.query.IndexedItem;
+import com.yahoo.prelude.query.IntItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.MarkerWordItem;
+import com.yahoo.prelude.query.NearItem;
+import com.yahoo.prelude.query.NotItem;
+import com.yahoo.prelude.query.NullItem;
+import com.yahoo.prelude.query.ONearItem;
+import com.yahoo.prelude.query.OrItem;
+import com.yahoo.prelude.query.PhraseItem;
+import com.yahoo.prelude.query.PhraseSegmentItem;
+import com.yahoo.prelude.query.PredicateQueryItem;
+import com.yahoo.prelude.query.PrefixItem;
+import com.yahoo.prelude.query.RangeItem;
+import com.yahoo.prelude.query.RankItem;
+import com.yahoo.prelude.query.RegExpItem;
+import com.yahoo.prelude.query.SegmentingRule;
+import com.yahoo.prelude.query.Substring;
+import com.yahoo.prelude.query.SubstringItem;
+import com.yahoo.prelude.query.SuffixItem;
+import com.yahoo.prelude.query.TaggableItem;
+import com.yahoo.prelude.query.ToolBox;
+import com.yahoo.prelude.query.ToolBox.QueryVisitor;
+import com.yahoo.prelude.query.WandItem;
+import com.yahoo.prelude.query.WeakAndItem;
+import com.yahoo.prelude.query.WeightedSetItem;
+import com.yahoo.prelude.query.WordAlternativesItem;
+import com.yahoo.prelude.query.WordItem;
+import com.yahoo.search.Query;
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.grouping.GroupingRequest;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+/**
+ * Serialize Vespa query trees to YQL+ strings.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class VespaSerializer {
+ // TODO refactor, too much copy/paste
+
+ private static class AndSegmentSerializer extends Serializer {
+ private static void serializeWords(StringBuilder destination,
+ AndSegmentItem segment) {
+ for (int i = 0; i < segment.getItemCount(); ++i) {
+ if (i > 0) {
+ destination.append(", ");
+ }
+ Item current = segment.getItem(i);
+ if (current instanceof WordItem) {
+ destination.append('"');
+ escape(((WordItem) current).getIndexedString(), destination)
+ .append('"');
+ } else {
+ throw new IllegalArgumentException(
+ "Serializing of "
+ + current.getClass().getSimpleName()
+ + " in segment AND expressions not implemented, please report this as a bug.");
+ }
+ }
+ }
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ return serialize(destination, item, true);
+ }
+
+ static boolean serialize(StringBuilder destination, Item item,
+ boolean includeField) {
+ AndSegmentItem phrase = (AndSegmentItem) item;
+ Substring origin = phrase.getOrigin();
+ String image;
+ int offset;
+ int length;
+
+ if (origin == null) {
+ image = phrase.getRawWord();
+ offset = 0;
+ length = image.length();
+ } else {
+ image = origin.getSuperstring();
+ offset = origin.start;
+ length = origin.end - origin.start;
+ }
+
+ if (includeField) {
+ destination.append(normalizeIndexName(phrase.getIndexName()))
+ .append(" contains ");
+ }
+ destination.append("([{");
+ serializeOrigin(destination, image, offset, length);
+ destination.append(", \"").append(AND_SEGMENTING)
+ .append("\": true");
+ destination.append("}]");
+ destination.append(PHRASE).append('(');
+ serializeWords(destination, phrase);
+ destination.append("))");
+ return false;
+ }
+ }
+
+ private static class AndSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ destination.append(')');
+ }
+
+ @Override
+ String separator(Deque<SerializerWrapper> state) {
+ return " AND ";
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ destination.append("(");
+ return true;
+ }
+ }
+
+ private static class DotProductSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ serializeWeightedSetContents(destination, DOT_PRODUCT,
+ (WeightedSetItem) item);
+ return false;
+ }
+
+ }
+
+ private static class EquivSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ EquivItem e = (EquivItem) item;
+ String annotations = leafAnnotations(e);
+ destination.append(getIndexName(e.getItem(0))).append(" contains ");
+ if (annotations.length() > 0) {
+ destination.append("([{").append(annotations).append("}]");
+ }
+ destination.append(EQUIV).append('(');
+ int initLen = destination.length();
+ for (Iterator<Item> i = e.getItemIterator(); i.hasNext();) {
+ Item x = i.next();
+ if (destination.length() > initLen) {
+ destination.append(", ");
+ }
+ if (x instanceof PhraseItem) {
+ PhraseSerializer.serialize(destination, x, false);
+ } else {
+ destination.append('"');
+ escape(((IndexedItem) x).getIndexedString(), destination);
+ destination.append('"');
+ }
+ }
+ if (annotations.length() > 0) {
+ destination.append(')');
+ }
+ destination.append(')');
+ return false;
+ }
+
+ }
+
+ private static class NearSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ NearItem n = (NearItem) item;
+ String annotations = nearAnnotations(n);
+
+ destination.append(getIndexName(n.getItem(0))).append(" contains ");
+ if (annotations.length() > 0) {
+ destination.append('(').append(annotations);
+ }
+ destination.append(NEAR).append('(');
+ int initLen = destination.length();
+ for (ListIterator<Item> i = n.getItemIterator(); i.hasNext();) {
+ WordItem close = (WordItem) i.next();
+ if (destination.length() > initLen) {
+ destination.append(", ");
+ }
+ destination.append('"');
+ escape(close.getIndexedString(), destination).append('"');
+ }
+ destination.append(')');
+ if (annotations.length() > 0) {
+ destination.append(')');
+ }
+ return false;
+ }
+
+ static String nearAnnotations(NearItem n) {
+ if (n.getDistance() != NearItem.defaultDistance) {
+ return "[{\"" + DISTANCE + "\": " + n.getDistance() + "}]";
+ } else {
+ return "";
+ }
+ }
+
+ }
+
+ private static class NotSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ destination.append(')');
+ }
+
+ @Override
+ String separator(Deque<SerializerWrapper> state) {
+ if (state.peekFirst().subItems == 1) {
+ return ") AND !(";
+ } else {
+ return " OR ";
+ }
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ destination.append("(");
+ return true;
+ }
+ }
+
+ private static class NullSerializer extends Serializer {
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ throw new NullItemException(
+ "NullItem encountered in query tree."
+ + " This is usually a symptom of an invalid query or an error"
+ + " in a query transformer.");
+ }
+ }
+
+ private static class NumberSerializer extends Serializer {
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ IntItem intItem = (IntItem) item;
+ if (intItem.getFromLimit().number()
+ .equals(intItem.getToLimit().number())) {
+ destination.append(normalizeIndexName(intItem.getIndexName()))
+ .append(" = ");
+ annotatedNumberImage(intItem, intItem.getFromLimit().number()
+ .toString(), destination);
+ } else if (intItem.getFromLimit().isInfinite()) {
+ destination.append(normalizeIndexName(intItem.getIndexName()));
+ destination.append(intItem.getToLimit().isInclusive() ? " <= "
+ : " < ");
+ annotatedNumberImage(intItem, intItem.getToLimit().number()
+ .toString(), destination);
+ } else if (intItem.getToLimit().isInfinite()) {
+ destination.append(normalizeIndexName(intItem.getIndexName()));
+ destination
+ .append(intItem.getFromLimit().isInclusive() ? " >= "
+ : " > ");
+ annotatedNumberImage(intItem, intItem.getFromLimit().number()
+ .toString(), destination);
+ } else {
+ serializeAsRange(destination, intItem);
+ }
+ return false;
+ }
+
+ private void serializeAsRange(StringBuilder destination, IntItem intItem) {
+ String annotations = leafAnnotations(intItem);
+ boolean leftOpen = !intItem.getFromLimit().isInclusive();
+ boolean rightOpen = !intItem.getToLimit().isInclusive();
+ String boundsAnnotation = "";
+ int initLen;
+
+ if (leftOpen && rightOpen) {
+ boundsAnnotation = "\"" + BOUNDS + "\": " + "\"" + BOUNDS_OPEN
+ + "\"";
+ } else if (leftOpen) {
+ boundsAnnotation = "\"" + BOUNDS + "\": " + "\""
+ + BOUNDS_LEFT_OPEN + "\"";
+ } else if (rightOpen) {
+ boundsAnnotation = "\"" + BOUNDS + "\": " + "\""
+ + BOUNDS_RIGHT_OPEN + "\"";
+ }
+ if (annotations.length() > 0 || boundsAnnotation.length() > 0) {
+ destination.append("[{");
+ }
+ initLen = destination.length();
+ if (annotations.length() > 0) {
+
+ destination.append(annotations);
+ }
+ comma(destination, initLen);
+ if (boundsAnnotation.length() > 0) {
+ destination.append(boundsAnnotation);
+ }
+ if (initLen != annotations.length()) {
+ destination.append("}]");
+ }
+ destination.append(RANGE).append('(')
+ .append(normalizeIndexName(intItem.getIndexName()))
+ .append(", ").append(intItem.getFromLimit().number())
+ .append(", ").append(intItem.getToLimit().number())
+ .append(")");
+ }
+
+ private void annotatedNumberImage(IntItem item, String rawNumber,
+ StringBuilder image) {
+ String annotations = leafAnnotations(item);
+
+ if (annotations.length() > 0) {
+ image.append("([{").append(annotations).append("}]");
+ }
+ if ('-' == rawNumber.charAt(0)) {
+ image.append('(');
+ }
+ image.append(rawNumber);
+ appendLongIfNecessary(rawNumber, image);
+ if ('-' == rawNumber.charAt(0)) {
+ image.append(')');
+ }
+ if (annotations.length() > 0) {
+ image.append(')');
+ }
+ }
+
+ private void appendLongIfNecessary(String rawNumber, StringBuilder image) {
+ // floating point
+ if (rawNumber.indexOf('.') >= 0) {
+ return;
+ }
+ try {
+ long l = Long.parseLong(rawNumber);
+ if (l < Integer.MIN_VALUE || l > Integer.MAX_VALUE) {
+ image.append('L');
+ }
+ } catch (NumberFormatException e) {
+ // somebody has managed to init an IntItem containing noise,
+ // just give up
+ return;
+ }
+ }
+ }
+
+ private static class RegExpSerializer extends Serializer {
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ RegExpItem regexp = (RegExpItem) item;
+
+ String annotations = leafAnnotations(regexp);
+ destination.append(normalizeIndexName(regexp.getIndexName())).append(
+ " matches ");
+ annotatedTerm(destination, regexp, annotations);
+ return false;
+ }
+ }
+
+ private static class ONearSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ NearItem n = (NearItem) item;
+ String annotations = NearSerializer.nearAnnotations(n);
+
+ destination.append(getIndexName(n.getItem(0))).append(" contains ");
+ if (annotations.length() > 0) {
+ destination.append('(').append(annotations);
+ }
+ destination.append(ONEAR).append('(');
+ int initLen = destination.length();
+ for (ListIterator<Item> i = n.getItemIterator(); i.hasNext();) {
+ WordItem close = (WordItem) i.next();
+ if (destination.length() > initLen) {
+ destination.append(", ");
+ }
+ destination.append('"');
+ escape(close.getIndexedString(), destination).append('"');
+ }
+ destination.append(')');
+ if (annotations.length() > 0) {
+ destination.append(')');
+ }
+ return false;
+ }
+
+ }
+
+ private static class OrSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ destination.append(')');
+ }
+
+ @Override
+ String separator(Deque<SerializerWrapper> state) {
+ return " OR ";
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ destination.append("(");
+ return true;
+ }
+ }
+
+ private static class PhraseSegmentSerializer extends Serializer {
+
+ private static void serializeWords(StringBuilder destination,
+ PhraseSegmentItem segment) {
+ for (int i = 0; i < segment.getItemCount(); ++i) {
+ if (i > 0) {
+ destination.append(", ");
+ }
+ Item current = segment.getItem(i);
+ if (current instanceof WordItem) {
+ destination.append('"');
+ escape(((WordItem) current).getIndexedString(), destination)
+ .append('"');
+ } else {
+ throw new IllegalArgumentException(
+ "Serializing of "
+ + current.getClass().getSimpleName()
+ + " in phrases not implemented, please report this as a bug.");
+ }
+ }
+ }
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ return serialize(destination, item, true);
+ }
+
+ static boolean serialize(StringBuilder destination, Item item,
+ boolean includeField) {
+ PhraseSegmentItem phrase = (PhraseSegmentItem) item;
+ Substring origin = phrase.getOrigin();
+ String image;
+ int offset;
+ int length;
+
+ if (includeField) {
+ destination.append(normalizeIndexName(phrase.getIndexName()))
+ .append(" contains ");
+ }
+ if (origin == null) {
+ image = phrase.getRawWord();
+ offset = 0;
+ length = image.length();
+ } else {
+ image = origin.getSuperstring();
+ offset = origin.start;
+ length = origin.end - origin.start;
+ }
+
+ destination.append("([{");
+ serializeOrigin(destination, image, offset, length);
+ String annotations = leafAnnotations(phrase);
+ if (annotations.length() > 0) {
+ destination.append(", ").append(annotations);
+ }
+ if (phrase.getSegmentingRule() == SegmentingRule.BOOLEAN_AND) {
+ destination.append(", ").append('"').append(AND_SEGMENTING)
+ .append("\": true");
+ }
+ destination.append("}]");
+ destination.append(PHRASE).append('(');
+ serializeWords(destination, phrase);
+ destination.append("))");
+ return false;
+ }
+ }
+
+ private static class PhraseSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ return serialize(destination, item, true);
+ }
+
+ static boolean serialize(StringBuilder destination, Item item,
+ boolean includeField) {
+
+ PhraseItem phrase = (PhraseItem) item;
+ String annotations = leafAnnotations(phrase);
+
+ if (includeField) {
+ destination.append(normalizeIndexName(phrase.getIndexName()))
+ .append(" contains ");
+
+ }
+ if (annotations.length() > 0) {
+ destination.append("([{").append(annotations).append("}]");
+ }
+
+ destination.append(PHRASE).append('(');
+ for (int i = 0; i < phrase.getItemCount(); ++i) {
+ if (i > 0) {
+ destination.append(", ");
+ }
+ Item current = phrase.getItem(i);
+ if (current instanceof WordItem) {
+ WordSerializer.serializeWordWithoutIndex(destination,
+ current);
+ } else if (current instanceof PhraseSegmentItem) {
+ PhraseSegmentSerializer.serialize(destination, current,
+ false);
+ } else if (current instanceof WordAlternativesItem) {
+ WordAlternativesSerializer.serialize(destination, (WordAlternativesItem) current, false);
+ } else {
+ throw new IllegalArgumentException(
+ "Serializing of "
+ + current.getClass().getSimpleName()
+ + " in phrases not implemented, please report this as a bug.");
+ }
+ }
+ destination.append(')');
+ if (annotations.length() > 0) {
+ destination.append(')');
+ }
+ return false;
+ }
+
+ }
+
+ private static class PredicateQuerySerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ PredicateQueryItem pItem = (PredicateQueryItem) item;
+ destination.append("predicate(").append(pItem.getIndexName())
+ .append(',');
+ appendFeatures(destination, pItem.getFeatures());
+ destination.append(',');
+ appendFeatures(destination, pItem.getRangeFeatures());
+ destination.append(')');
+ return false;
+ }
+
+ private void appendFeatures(StringBuilder destination,
+ Collection<? extends PredicateQueryItem.EntryBase> features) {
+ if (features.isEmpty()) {
+ destination.append('0'); // Workaround for empty maps.
+ return;
+ }
+ destination.append('{');
+ boolean first = true;
+ for (PredicateQueryItem.EntryBase entry : features) {
+ if (!first) {
+ destination.append(',');
+ }
+ if (entry.getSubQueryBitmap() != PredicateQueryItem.ALL_SUB_QUERIES) {
+ destination.append("\"0x").append(
+ Long.toHexString(entry.getSubQueryBitmap()));
+ destination.append("\":{");
+ appendKeyValue(destination, entry);
+ destination.append('}');
+ } else {
+ appendKeyValue(destination, entry);
+ }
+ first = false;
+ }
+ destination.append('}');
+ }
+
+ private void appendKeyValue(StringBuilder destination,
+ PredicateQueryItem.EntryBase entry) {
+ destination.append('"');
+ escape(entry.getKey(), destination);
+ destination.append("\":");
+ if (entry instanceof PredicateQueryItem.Entry) {
+ destination.append('"');
+ escape(((PredicateQueryItem.Entry) entry).getValue(),
+ destination);
+ destination.append('"');
+ } else {
+ destination.append(((PredicateQueryItem.RangeEntry) entry)
+ .getValue());
+ destination.append('L');
+ }
+ }
+
+ }
+
+ private static class RangeSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ RangeItem range = (RangeItem) item;
+ String annotations = leafAnnotations(range);
+ if (annotations.length() > 0) {
+ destination.append("[{").append(annotations).append("}]");
+ }
+ destination.append(RANGE).append('(')
+ .append(normalizeIndexName(range.getIndexName()))
+ .append(", ");
+ appendNumberImage(destination, range.getFrom()); // TODO: Serialize
+ // inclusive/exclusive
+ destination.append(", ");
+ appendNumberImage(destination, range.getTo());
+ destination.append(')');
+ return false;
+ }
+
+ private void appendNumberImage(StringBuilder destination, Number number) {
+ destination.append(number.toString());
+ if (number instanceof Long) {
+ destination.append('L');
+ }
+ }
+ }
+
+ private static class RankSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ destination.append(')');
+ }
+
+ @Override
+ String separator(Deque<SerializerWrapper> state) {
+ return ", ";
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ destination.append(RANK).append('(');
+ return true;
+
+ }
+
+ }
+
+ private static class WordAlternativesSerializer extends Serializer {
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ return serialize(destination, (WordAlternativesItem) item, true);
+ }
+
+ static boolean serialize(StringBuilder destination, WordAlternativesItem alternatives, boolean includeField) {
+ String annotations = leafAnnotations(alternatives);
+ Substring origin = alternatives.getOrigin();
+ boolean isFromQuery = alternatives.isFromQuery();
+ boolean needsAnnotations = annotations.length() > 0 || origin != null || !isFromQuery;
+
+ if (includeField) {
+ destination.append(normalizeIndexName(alternatives.getIndexName())).append(" contains ");
+ }
+
+ if (needsAnnotations) {
+ destination.append("([{");
+ int initLen = destination.length();
+
+ if (origin != null) {
+ String image = origin.getSuperstring();
+ int offset = origin.start;
+ int length = origin.end - origin.start;
+ serializeOrigin(destination, image, offset, length);
+ }
+ if (!isFromQuery) {
+ comma(destination, initLen);
+ destination.append('"').append(IMPLICIT_TRANSFORMS).append("\": false");
+ }
+ if (annotations.length() > 0) {
+ comma(destination, initLen);
+ destination.append(annotations);
+ }
+
+ destination.append("}]");
+ }
+
+ destination.append(ALTERNATIVES).append("({");
+ int initLen = destination.length();
+ List<WordAlternativesItem.Alternative> sortedAlternatives = new ArrayList<>(alternatives.getAlternatives());
+ // ensure most precise forms first
+ Collections.sort(sortedAlternatives, (x, y) -> Double.compare(y.exactness, x.exactness));
+ for (WordAlternativesItem.Alternative alternative : sortedAlternatives) {
+ comma(destination, initLen);
+ destination.append('"');
+ escape(alternative.word, destination);
+ destination.append("\": ").append(Double.toString(alternative.exactness));
+ }
+ destination.append("})");
+ if (needsAnnotations) {
+ destination.append(')');
+ }
+ return false;
+ }
+ }
+
+ private static abstract class Serializer {
+ abstract void onExit(StringBuilder destination, Item item);
+
+ String separator(Deque<SerializerWrapper> state) {
+ throw new UnsupportedOperationException(
+ "Having several items for this query operator serializer, "
+ + this.getClass().getSimpleName()
+ + ", not yet implemented.");
+ }
+
+ abstract boolean serialize(StringBuilder destination, Item item);
+ }
+
+ private static final class SerializerWrapper {
+ int subItems;
+ final Serializer type;
+ final Item item;
+
+ SerializerWrapper(Serializer type, Item item) {
+ subItems = 0;
+ this.type = type;
+ this.item = item;
+ }
+
+ }
+
+ private static final class TokenComparator implements
+ Comparator<Entry<Object, Integer>> {
+
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ @Override
+ public int compare(Entry<Object, Integer> o1, Entry<Object, Integer> o2) {
+ Comparable c1 = (Comparable) o1.getKey();
+ Comparable c2 = (Comparable) o2.getKey();
+ return c1.compareTo(c2);
+ }
+ }
+
+ private static class VespaVisitor extends QueryVisitor {
+
+ final StringBuilder destination;
+ final Deque<SerializerWrapper> state = new ArrayDeque<>();
+
+ VespaVisitor(StringBuilder destination) {
+ this.destination = destination;
+ }
+
+ @Override
+ public void onExit() {
+ SerializerWrapper w = state.removeFirst();
+ w.type.onExit(destination, w.item);
+ w = state.peekFirst();
+ if (w != null) {
+ w.subItems += 1;
+ }
+ }
+
+ @Override
+ public boolean visit(Item item) {
+ Serializer doIt = dispatch.get(item.getClass());
+
+ if (doIt == null) {
+ throw new IllegalArgumentException(item.getClass()
+ + " not supported for YQL+ marshalling.");
+ }
+
+ if (state.peekFirst() != null && state.peekFirst().subItems > 0) {
+ destination.append(state.peekFirst().type.separator(state));
+ }
+ state.addFirst(new SerializerWrapper(doIt, item));
+ return doIt.serialize(destination, item);
+
+ }
+ }
+
+ private static class WandSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ serializeWeightedSetContents(destination, WAND,
+ (WeightedSetItem) item,
+ specificAnnotations((WandItem) item));
+ return false;
+ }
+
+ private String specificAnnotations(WandItem w) {
+ StringBuilder annotations = new StringBuilder();
+ int targetNumHits = w.getTargetNumHits();
+ double scoreThreshold = w.getScoreThreshold();
+ double thresholdBoostFactor = w.getThresholdBoostFactor();
+ if (targetNumHits != 10) {
+ annotations.append('"').append(TARGET_NUM_HITS).append("\": ")
+ .append(targetNumHits);
+ }
+ if (scoreThreshold != 0) {
+ comma(annotations, 0);
+ annotations.append('"').append(SCORE_THRESHOLD).append("\": ")
+ .append(scoreThreshold);
+ }
+ if (thresholdBoostFactor != 1) {
+ comma(annotations, 0);
+ annotations.append('"').append(THRESHOLD_BOOST_FACTOR)
+ .append("\": ").append(thresholdBoostFactor);
+ }
+ return annotations.toString();
+ }
+
+ }
+
+ private static class WeakAndSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ destination.append(')');
+ if (needsAnnotationBlock((WeakAndItem) item)) {
+ destination.append(')');
+ }
+ }
+
+ @Override
+ String separator(Deque<SerializerWrapper> state) {
+ return ", ";
+ }
+
+ private boolean needsAnnotationBlock(WeakAndItem item) {
+ return nonDefaultScoreThreshold(item) || nonDefaultTargetNumHits(item);
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ WeakAndItem w = (WeakAndItem) item;
+ if (needsAnnotationBlock(w)) {
+ destination.append("([{");
+ }
+ int lengthBeforeAnnotations = destination.length();
+ if (nonDefaultTargetNumHits(w)) {
+ destination.append('"').append(TARGET_NUM_HITS).append("\": ").append(w.getN());
+ }
+ if (nonDefaultScoreThreshold(w)) {
+ comma(destination, lengthBeforeAnnotations);
+ destination.append('"').append(SCORE_THRESHOLD).append("\": ").append(w.getScoreThreshold());
+ }
+ if (needsAnnotationBlock(w)) {
+ destination.append("}]");
+ }
+ destination.append(WEAK_AND).append('(');
+ return true;
+ }
+
+ private boolean nonDefaultScoreThreshold(WeakAndItem w) {
+ return w.getScoreThreshold() > 0;
+ }
+
+ private boolean nonDefaultTargetNumHits(WeakAndItem w) {
+ return w.getN() != WeakAndItem.defaultN;
+ }
+ }
+
+ private static class WeightedSetSerializer extends Serializer {
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ serializeWeightedSetContents(destination, WEIGHTED_SET,
+ (WeightedSetItem) item);
+ return false;
+ }
+
+ }
+
+ private static class WordSerializer extends Serializer {
+
+ @Override
+ void onExit(StringBuilder destination, Item item) {
+ }
+
+ @Override
+ boolean serialize(StringBuilder destination, Item item) {
+ WordItem w = (WordItem) item;
+ StringBuilder wordAnnotations = getAllAnnotations(w);
+
+ destination.append(normalizeIndexName(w.getIndexName())).append(
+ " contains ");
+ VespaSerializer.annotatedTerm(destination, w, wordAnnotations.toString());
+ return false;
+ }
+
+ static void serializeWordWithoutIndex(StringBuilder destination,
+ Item item) {
+ WordItem w = (WordItem) item;
+ StringBuilder wordAnnotations = getAllAnnotations(w);
+
+ VespaSerializer.annotatedTerm(destination, w, wordAnnotations.toString());
+ }
+
+ private static StringBuilder getAllAnnotations(WordItem w) {
+ StringBuilder wordAnnotations = new StringBuilder(
+ WordSerializer.wordAnnotations(w));
+ String leafAnnotations = leafAnnotations(w);
+
+ if (leafAnnotations.length() > 0) {
+ comma(wordAnnotations, 0);
+ wordAnnotations.append(leafAnnotations(w));
+ }
+ return wordAnnotations;
+ }
+
+ private static String wordAnnotations(WordItem item) {
+ Substring origin = item.getOrigin();
+ boolean usePositionData = item.usePositionData();
+ boolean stemmed = item.isStemmed();
+ boolean lowercased = item.isLowercased();
+ boolean accentDrop = item.isNormalizable();
+ SegmentingRule andSegmenting = item.getSegmentingRule();
+ boolean isFromQuery = item.isFromQuery();
+ StringBuilder annotation = new StringBuilder();
+ boolean prefix = item instanceof PrefixItem;
+ boolean suffix = item instanceof SuffixItem;
+ boolean substring = item instanceof SubstringItem;
+ int initLen = annotation.length();
+ String image;
+ int offset;
+ int length;
+
+ if (origin == null) {
+ image = item.getRawWord();
+ offset = 0;
+ length = image.length();
+ } else {
+ image = origin.getSuperstring();
+ offset = origin.start;
+ length = origin.end - origin.start;
+ }
+
+ if (!image.substring(offset, offset + length).equals(
+ item.getIndexedString())) {
+ VespaSerializer.serializeOrigin(annotation, image, offset,
+ length);
+ }
+ if (usePositionData != true) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(USE_POSITION_DATA)
+ .append("\": false");
+ }
+ if (stemmed == true) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(STEM).append("\": false");
+ }
+ if (lowercased == true) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(NORMALIZE_CASE)
+ .append("\": false");
+ }
+ if (accentDrop == false) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(ACCENT_DROP).append("\": false");
+ }
+ if (andSegmenting == SegmentingRule.BOOLEAN_AND) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(AND_SEGMENTING)
+ .append("\": true");
+ }
+ if (!isFromQuery) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(IMPLICIT_TRANSFORMS)
+ .append("\": false");
+ }
+ if (prefix) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(PREFIX).append("\": true");
+ }
+ if (suffix) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(SUFFIX).append("\": true");
+ }
+ if (substring) {
+ VespaSerializer.comma(annotation, initLen);
+ annotation.append('"').append(SUBSTRING).append("\": true");
+ }
+ return annotation.toString();
+ }
+
+ }
+
+ private static final char[] DIGITS = new char[] { '0', '1', '2', '3', '4',
+ '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
+
+ private static final Map<Class<?>, Serializer> dispatch;
+
+ private static final Comparator<? super Entry<Object, Integer>> tokenComparator = new TokenComparator();
+
+ static {
+ Map<Class<?>, Serializer> dispatchBuilder = new HashMap<>();
+ dispatchBuilder.put(AndItem.class, new AndSerializer());
+ dispatchBuilder.put(AndSegmentItem.class, new AndSegmentSerializer());
+ dispatchBuilder.put(DotProductItem.class, new DotProductSerializer());
+ dispatchBuilder.put(EquivItem.class, new EquivSerializer());
+ dispatchBuilder.put(IntItem.class, new NumberSerializer());
+ dispatchBuilder.put(MarkerWordItem.class, new WordSerializer()); // gotcha
+ dispatchBuilder.put(NearItem.class, new NearSerializer());
+ dispatchBuilder.put(NotItem.class, new NotSerializer());
+ dispatchBuilder.put(NullItem.class, new NullSerializer());
+ dispatchBuilder.put(ONearItem.class, new ONearSerializer());
+ dispatchBuilder.put(OrItem.class, new OrSerializer());
+ dispatchBuilder.put(PhraseItem.class, new PhraseSerializer());
+ dispatchBuilder.put(PhraseSegmentItem.class, new PhraseSegmentSerializer());
+ dispatchBuilder.put(PredicateQueryItem.class,
+ new PredicateQuerySerializer());
+ dispatchBuilder.put(PrefixItem.class, new WordSerializer()); // gotcha
+ dispatchBuilder.put(WordAlternativesItem.class, new WordAlternativesSerializer());
+ dispatchBuilder.put(RangeItem.class, new RangeSerializer());
+ dispatchBuilder.put(RankItem.class, new RankSerializer());
+ dispatchBuilder.put(SubstringItem.class, new WordSerializer()); // gotcha
+ dispatchBuilder.put(SuffixItem.class, new WordSerializer()); // gotcha
+ dispatchBuilder.put(WandItem.class, new WandSerializer());
+ dispatchBuilder.put(WeakAndItem.class, new WeakAndSerializer());
+ dispatchBuilder.put(WeightedSetItem.class, new WeightedSetSerializer());
+ dispatchBuilder.put(WordItem.class, new WordSerializer());
+ dispatchBuilder.put(RegExpItem.class, new RegExpSerializer());
+ dispatch = ImmutableMap.copyOf(dispatchBuilder);
+ }
+
+ /**
+ * Do YQL+ escaping, which is basically the same as for JSON, of the
+ * incoming string to the "quoted" buffer. The buffer returned is the same
+ * as the one given in the "quoted" parameter.
+ *
+ * @param in a string to escape
+ * @param escaped the target buffer for escaped data
+ * @return the same buffer as given in the "quoted" parameter
+ */
+ private static StringBuilder escape(String in, StringBuilder escaped) {
+ for (char c : in.toCharArray()) {
+ switch (c) {
+ case ('\b'):
+ escaped.append("\\b");
+ break;
+ case ('\t'):
+ escaped.append("\\t");
+ break;
+ case ('\n'):
+ escaped.append("\\n");
+ break;
+ case ('\f'):
+ escaped.append("\\f");
+ break;
+ case ('\r'):
+ escaped.append("\\r");
+ break;
+ case ('"'):
+ escaped.append("\\\"");
+ break;
+ case ('\''):
+ escaped.append("\\'");
+ break;
+ case ('\\'):
+ escaped.append("\\\\");
+ break;
+ case ('/'):
+ escaped.append("\\/");
+ break;
+ default:
+ if (c < 32 || c >= 127) {
+ escaped.append("\\u").append(fourDigitHexString(c));
+ } else {
+ escaped.append(c);
+ }
+ }
+ }
+ return escaped;
+ }
+
+ private static char[] fourDigitHexString(char c) {
+ char[] hex = new char[4];
+ int in = ((c) & 0xFFFF);
+ for (int i = 3; i >= 0; --i) {
+ hex[i] = DIGITS[in & 0xF];
+ in >>>= 4;
+ }
+ return hex;
+ }
+
+ static String getIndexName(Item item) {
+ if (!(item instanceof IndexedItem))
+ throw new IllegalArgumentException("Expected IndexedItem, got " + item.getClass());
+ return normalizeIndexName(((IndexedItem) item).getIndexName());
+ }
+
+ public static String serialize(Query query) {
+ StringBuilder out = new StringBuilder();
+ serialize(query.getModel().getQueryTree().getRoot(), out);
+ for (GroupingRequest request : GroupingRequest.getRequests(query)) {
+ out.append(" | ");
+ serialize(request, out);
+ }
+ return out.toString();
+ }
+
+ private static void serialize(GroupingRequest request, StringBuilder out) {
+ Iterator<Continuation> it = request.continuations().iterator();
+ if (it.hasNext()) {
+ out.append("[{ 'continuations':[");
+ while (it.hasNext()) {
+ out.append('\'').append(it.next()).append('\'');
+ if (it.hasNext()) {
+ out.append(", ");
+ }
+ }
+ out.append("] }]");
+ }
+ out.append(request.getRootOperation());
+ }
+
+ private static void serialize(Item item, StringBuilder out) {
+ VespaVisitor visitor = new VespaVisitor(out);
+ ToolBox.visit(visitor, item);
+ }
+
+ static String serialize(Item item) {
+ StringBuilder out = new StringBuilder();
+ serialize(item, out);
+ return out.toString();
+ }
+
+ private static void serializeWeightedSetContents(StringBuilder destination,
+ String opName, WeightedSetItem weightedSet) {
+ serializeWeightedSetContents(destination, opName, weightedSet, "");
+ }
+
+ private static void serializeWeightedSetContents(
+ StringBuilder destination,
+ String opName, WeightedSetItem weightedSet,
+ String optionalAnnotations) {
+ addAnnotations(destination, weightedSet, optionalAnnotations);
+ destination.append(opName).append('(')
+ .append(normalizeIndexName(weightedSet.getIndexName()))
+ .append(", {");
+ int initLen = destination.length();
+ List<Entry<Object, Integer>> tokens = new ArrayList<>(
+ weightedSet.getNumTokens());
+ for (Iterator<Entry<Object, Integer>> i = weightedSet.getTokens(); i
+ .hasNext();) {
+ tokens.add(i.next());
+ }
+ Collections.sort(tokens, tokenComparator);
+ for (Entry<Object, Integer> entry : tokens) {
+ comma(destination, initLen);
+ destination.append('"');
+ escape(entry.getKey().toString(), destination);
+ destination.append("\": ").append(entry.getValue().toString());
+ }
+ destination.append("})");
+ }
+
+ private static void addAnnotations(
+ StringBuilder destination,
+ WeightedSetItem weightedSet, String optionalAnnotations) {
+ int preAnnotationValueLen;
+ int incomingLen = destination.length();
+ String annotations = leafAnnotations(weightedSet);
+
+ if (optionalAnnotations.length() > 0 || annotations.length() > 0) {
+ destination.append("[{");
+ }
+ preAnnotationValueLen = destination.length();
+ if (annotations.length() > 0) {
+ destination.append(annotations);
+ }
+ if (optionalAnnotations.length() > 0) {
+ comma(destination, preAnnotationValueLen);
+ destination.append(optionalAnnotations);
+ }
+ if (destination.length() > incomingLen) {
+ destination.append("}]");
+ }
+ }
+
+ private static void comma(StringBuilder annotation, int initLen) {
+ if (annotation.length() > initLen) {
+ annotation.append(", ");
+ }
+ }
+
+ private static String leafAnnotations(TaggableItem item) {
+ // TODO there is no usable API for the general annotations map in the
+ // Item instances
+ StringBuilder annotation = new StringBuilder();
+ int initLen = annotation.length();
+ {
+ int uniqueId = item.getUniqueID();
+ double connectivity = item.getConnectivity();
+ TaggableItem connectedTo = (TaggableItem) item.getConnectedItem();
+ double significance = item.getSignificance();
+ if (connectedTo != null && connectedTo.getUniqueID() != 0) {
+ annotation.append('"').append(CONNECTIVITY).append("\": {\"")
+ .append(CONNECTION_ID).append("\": ")
+ .append(connectedTo.getUniqueID()).append(", \"")
+ .append(CONNECTION_WEIGHT).append("\": ")
+ .append(connectivity).append("}");
+ }
+ if (item.hasExplicitSignificance()) {
+ comma(annotation, initLen);
+ annotation.append('"').append(SIGNIFICANCE).append("\": ")
+ .append(significance);
+ }
+ if (uniqueId != 0) {
+ comma(annotation, initLen);
+ annotation.append('"').append(UNIQUE_ID).append("\": ")
+ .append(uniqueId);
+ }
+ }
+ {
+ Item leaf = (Item) item;
+ boolean filter = leaf.isFilter();
+ boolean isRanked = leaf.isRanked();
+ String label = leaf.getLabel();
+ int weight = leaf.getWeight();
+
+ if (filter == true) {
+ comma(annotation, initLen);
+ annotation.append("\"").append(FILTER).append("\": true");
+ }
+ if (isRanked == false) {
+ comma(annotation, initLen);
+ annotation.append("\"").append(RANKED).append("\": false");
+ }
+ if (label != null) {
+ comma(annotation, initLen);
+ annotation.append("\"").append(LABEL).append("\": \"");
+ escape(label, annotation);
+ annotation.append("\"");
+ }
+ if (weight != 100) {
+ comma(annotation, initLen);
+ annotation.append('"').append(WEIGHT).append("\": ")
+ .append(weight);
+ }
+ }
+ if (item instanceof IntItem) {
+ int hitLimit = ((IntItem) item).getHitLimit();
+ if (hitLimit != 0) {
+ comma(annotation, initLen);
+ annotation.append('"').append(HIT_LIMIT).append("\": ")
+ .append(hitLimit);
+ }
+ }
+ return annotation.toString();
+ }
+
+ private static void serializeOrigin(StringBuilder destination,
+ String image, int offset, int length) {
+ destination.append('"').append(ORIGIN).append("\": {\"")
+ .append(ORIGIN_ORIGINAL).append("\": \"");
+ escape(image, destination);
+ destination.append("\", \"").append(ORIGIN_OFFSET).append("\": ")
+ .append(offset).append(", \"").append(ORIGIN_LENGTH)
+ .append("\": ").append(length).append("}");
+ }
+
+ private static String normalizeIndexName(@NonNull String indexName) {
+ if (indexName.length() == 0) {
+ return "default";
+ } else {
+ return indexName;
+ }
+ }
+
+ private static void annotatedTerm(StringBuilder destination, IndexedItem w, String annotations) {
+ if (annotations.length() > 0) {
+ destination.append("([{").append(annotations).append("}]");
+ }
+ destination.append('"');
+ escape(w.getIndexedString(), destination).append('"');
+ if (annotations.length() > 0) {
+ destination.append(')');
+ }
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/YqlParser.java b/container-search/src/main/java/com/yahoo/search/yql/YqlParser.java
new file mode 100644
index 00000000000..a7cc06c95f7
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/YqlParser.java
@@ -0,0 +1,1894 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+import java.math.BigInteger;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringTokenizer;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.Preconditions;
+import com.yahoo.collections.LazyMap;
+import com.yahoo.collections.LazySet;
+import com.yahoo.collections.Tuple2;
+import com.yahoo.component.Version;
+import com.yahoo.language.Language;
+import com.yahoo.language.Linguistics;
+import com.yahoo.language.process.Normalizer;
+import com.yahoo.language.process.Segmenter;
+import com.yahoo.prelude.IndexFacts;
+import com.yahoo.prelude.query.AndItem;
+import com.yahoo.prelude.query.AndSegmentItem;
+import com.yahoo.prelude.query.CompositeItem;
+import com.yahoo.prelude.query.DotProductItem;
+import com.yahoo.prelude.query.EquivItem;
+import com.yahoo.prelude.query.IntItem;
+import com.yahoo.prelude.query.Item;
+import com.yahoo.prelude.query.Limit;
+import com.yahoo.prelude.query.NearItem;
+import com.yahoo.prelude.query.NotItem;
+import com.yahoo.prelude.query.NullItem;
+import com.yahoo.prelude.query.ONearItem;
+import com.yahoo.prelude.query.OrItem;
+import com.yahoo.prelude.query.PhraseItem;
+import com.yahoo.prelude.query.PhraseSegmentItem;
+import com.yahoo.prelude.query.PredicateQueryItem;
+import com.yahoo.prelude.query.PrefixItem;
+import com.yahoo.prelude.query.RangeItem;
+import com.yahoo.prelude.query.RankItem;
+import com.yahoo.prelude.query.RegExpItem;
+import com.yahoo.prelude.query.SegmentItem;
+import com.yahoo.prelude.query.SegmentingRule;
+import com.yahoo.prelude.query.Substring;
+import com.yahoo.prelude.query.SubstringItem;
+import com.yahoo.prelude.query.SuffixItem;
+import com.yahoo.prelude.query.TaggableItem;
+import com.yahoo.prelude.query.ToolBox;
+import com.yahoo.prelude.query.ToolBox.QueryVisitor;
+import com.yahoo.prelude.query.WandItem;
+import com.yahoo.prelude.query.WeakAndItem;
+import com.yahoo.prelude.query.WeightedSetItem;
+import com.yahoo.prelude.query.WordAlternativesItem;
+import com.yahoo.prelude.query.WordItem;
+import com.yahoo.search.Query;
+import com.yahoo.search.grouping.Continuation;
+import com.yahoo.search.grouping.request.GroupingOperation;
+import com.yahoo.search.query.QueryTree;
+import com.yahoo.search.query.Sorting;
+import com.yahoo.search.query.Sorting.AttributeSorter;
+import com.yahoo.search.query.Sorting.FieldOrder;
+import com.yahoo.search.query.Sorting.LowerCaseSorter;
+import com.yahoo.search.query.Sorting.Order;
+import com.yahoo.search.query.Sorting.RawSorter;
+import com.yahoo.search.query.Sorting.UcaSorter;
+import com.yahoo.search.query.parser.Parsable;
+import com.yahoo.search.query.parser.Parser;
+import com.yahoo.search.query.parser.ParserEnvironment;
+import com.yahoo.search.query.parser.ParserFactory;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+/**
+ * The YQL query language.
+ *
+ * <p>
+ * This class <em>must</em> be kept in lockstep with {@link VespaSerializer}.
+ * Adding anything here will usually require a corresponding addition in
+ * VespaSerializer.
+ * </p>
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ * @author <a href="mailto:stiankri@yahoo-inc.com">Stian Kristoffersen</a>
+ * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a>
+ */
+@Beta
+public class YqlParser implements Parser {
+
+ private static final String DESCENDING_HITS_ORDER = "descending";
+ private static final String ASCENDING_HITS_ORDER = "ascending";
+
+ private enum SegmentWhen {
+ NEVER, POSSIBLY, ALWAYS;
+ }
+
+ private static final Integer DEFAULT_HITS = 10;
+ private static final Integer DEFAULT_OFFSET = 0;
+ private static final Integer DEFAULT_TARGET_NUM_HITS = 10;
+ private static final String ACCENT_DROP_DESCRIPTION = "setting for whether to remove accents if field implies it";
+ private static final String ANNOTATIONS = "annotations";
+ private static final String FILTER_DESCRIPTION = "term filter setting";
+ private static final String IMPLICIT_TRANSFORMS_DESCRIPTION = "setting for whether built-in query transformers should touch the term";
+ private static final String NFKC = "nfkc";
+ private static final String NORMALIZE_CASE_DESCRIPTION = "setting for whether to do case normalization if field implies it";
+ private static final String ORIGIN_DESCRIPTION = "string origin for a term";
+ private static final String RANKED_DESCRIPTION = "setting for whether to use term for ranking";
+ private static final String SEGMENTER_BACKEND = "backend";
+ private static final String SEGMENTER = "segmenter";
+ private static final String SEGMENTER_VERSION = "version";
+ private static final String STEM_DESCRIPTION = "setting for whether to use stem if field implies it";
+ private static final String USE_POSITION_DATA_DESCRIPTION = "setting for whether to use position data for ranking this item";
+ private static final String USER_INPUT_ALLOW_EMPTY = "allowEmpty";
+ private static final String USER_INPUT_DEFAULT_INDEX = "defaultIndex";
+ private static final String USER_INPUT_GRAMMAR = "grammar";
+ private static final String USER_INPUT_LANGUAGE = "language";
+ private static final String USER_INPUT_RAW = "raw";
+ private static final String USER_INPUT_SEGMENT = "segment";
+ private static final String USER_INPUT = "userInput";
+ private static final String USER_QUERY = "userQuery";
+ private static final String NON_EMPTY = "nonEmpty";
+
+ public static final String SORTING_FUNCTION = "function";
+ public static final String SORTING_LOCALE = "locale";
+ public static final String SORTING_STRENGTH = "strength";
+
+ static final String ACCENT_DROP = "accentDrop";
+ static final String ALTERNATIVES = "alternatives";
+ static final String AND_SEGMENTING = "andSegmenting";
+ static final String BOUNDS = "bounds";
+ static final String BOUNDS_LEFT_OPEN = "leftOpen";
+ static final String BOUNDS_OPEN = "open";
+ static final String BOUNDS_RIGHT_OPEN = "rightOpen";
+ static final String CONNECTION_ID = "id";
+ static final String CONNECTION_WEIGHT = "weight";
+ static final String CONNECTIVITY = "connectivity";
+ static final String DISTANCE = "distance";
+ static final String DOT_PRODUCT = "dotProduct";
+ static final String EQUIV = "equiv";
+ static final String FILTER = "filter";
+ static final String HIT_LIMIT = "hitLimit";
+ static final String IMPLICIT_TRANSFORMS = "implicitTransforms";
+ static final String LABEL = "label";
+ static final String NEAR = "near";
+ static final String NORMALIZE_CASE = "normalizeCase";
+ static final String ONEAR = "onear";
+ static final String ORIGIN_LENGTH = "length";
+ static final String ORIGIN_OFFSET = "offset";
+ static final String ORIGIN = "origin";
+ static final String ORIGIN_ORIGINAL = "original";
+ static final String PHRASE = "phrase";
+ static final String PREDICATE = "predicate";
+ static final String PREFIX = "prefix";
+ static final String RANGE = "range";
+ static final String RANKED = "ranked";
+ static final String RANK = "rank";
+ static final String SCORE_THRESHOLD = "scoreThreshold";
+ static final String SIGNIFICANCE = "significance";
+ static final String STEM = "stem";
+ static final String SUBSTRING = "substring";
+ static final String SUFFIX = "suffix";
+ static final String TARGET_NUM_HITS = "targetNumHits";
+ static final String THRESHOLD_BOOST_FACTOR = "thresholdBoostFactor";
+ static final String UNIQUE_ID = "id";
+ static final String USE_POSITION_DATA = "usePositionData";
+ static final String WAND = "wand";
+ static final String WEAK_AND = "weakAnd";
+ static final String WEIGHTED_SET = "weightedSet";
+ static final String WEIGHT = "weight";
+
+ private final IndexFacts indexFacts;
+ private final List<ConnectedItem> connectedItems = new ArrayList<>();
+ private final List<VespaGroupingStep> groupingSteps = new ArrayList<>();
+ private final Map<Integer, TaggableItem> identifiedItems = LazyMap.newHashMap();
+ private final Normalizer normalizer;
+ private final Segmenter segmenter;
+ private final Set<String> yqlSources = LazySet.newHashSet();
+ private final Set<String> yqlSummaryFields = LazySet.newHashSet();
+ private final String localSegmenterBackend;
+ private final Version localSegmenterVersion;
+ private Integer hits;
+ private Integer offset;
+ private Integer timeout;
+ private Query userQuery;
+ private Parsable currentlyParsing;
+ private IndexFacts.Session indexFactsSession;
+ private Set<String> docTypes;
+ private Sorting sorting;
+ private String segmenterBackend;
+ private Version segmenterVersion;
+ private boolean queryParser = true;
+ private boolean resegment = false;
+ private final Deque<OperatorNode<?>> annotationStack = new ArrayDeque<>();
+ private final ParserEnvironment environment;
+
+ private static final QueryVisitor noEmptyTerms = new QueryVisitor() {
+
+ @Override
+ public boolean visit(Item item) {
+ if (item instanceof NullItem) {
+ throw new IllegalArgumentException("Got NullItem inside nonEmpty().");
+ } else if (item instanceof WordItem) {
+ if (((WordItem) item).getIndexedString().isEmpty()) {
+ throw new IllegalArgumentException("Searching for empty string inside nonEmpty()");
+ }
+ } else if (item instanceof CompositeItem) {
+ if (((CompositeItem) item).getItemCount() == 0) {
+ throw new IllegalArgumentException("Empty composite operator (" + item.getName() + ") inside nonEmpty()");
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void onExit() {
+ // NOP
+ }
+ };
+
+ public YqlParser(ParserEnvironment environment) {
+ indexFacts = environment.getIndexFacts();
+ normalizer = environment.getLinguistics().getNormalizer();
+ segmenter = environment.getLinguistics().getSegmenter();
+ this.environment = environment;
+
+ Tuple2<String, Version> version = environment.getLinguistics().getVersion(Linguistics.Component.SEGMENTER);
+ localSegmenterBackend = version.first;
+ localSegmenterVersion = version.second;
+ }
+
+ @NonNull
+ @Override
+ public QueryTree parse(Parsable query) {
+ indexFactsSession = indexFacts.newSession(query.getSources(), query.getRestrict());
+ connectedItems.clear();
+ groupingSteps.clear();
+ identifiedItems.clear();
+ yqlSources.clear();
+ yqlSummaryFields.clear();
+ annotationStack.clear();
+ hits = null;
+ offset = null;
+ timeout = null;
+ // userQuery set prior to calling this
+ currentlyParsing = query;
+ docTypes = null;
+ sorting = null;
+ segmenterBackend = null;
+ segmenterVersion = null;
+ // queryParser set prior to calling this
+ resegment = false;
+ return buildTree(fetchFilterPart());
+ }
+
+ private void joinDocTypesFromUserQueryAndYql() {
+ List<String> allSourceNames = new ArrayList<>(currentlyParsing.getSources().size() + yqlSources.size());
+ if ( ! yqlSources.isEmpty()) {
+ allSourceNames.addAll(currentlyParsing.getSources());
+ allSourceNames.addAll(yqlSources);
+ } else {
+ // no sources == all sources in Vespa
+ }
+ indexFactsSession = indexFacts.newSession(allSourceNames, currentlyParsing.getRestrict());
+ docTypes = new HashSet<>(indexFactsSession.documentTypes());
+ }
+
+ @NonNull
+ private QueryTree buildTree(OperatorNode<?> filterPart) {
+ Preconditions.checkArgument(filterPart.getArguments().length == 2,
+ "Expected 2 arguments to filter, got %s.",
+ filterPart.getArguments().length);
+ populateYqlSources(filterPart.<OperatorNode<?>> getArgument(0));
+ final OperatorNode<ExpressionOperator> filterExpression = filterPart
+ .getArgument(1);
+ populateLinguisticsAnnotations(filterExpression);
+ Item root = convertExpression(filterExpression);
+ connectItems();
+ userQuery = null;
+ return new QueryTree(root);
+ }
+
+ private void populateLinguisticsAnnotations(
+ OperatorNode<ExpressionOperator> filterExpression) {
+ Map<?, ?> segmenter = getAnnotation(filterExpression, SEGMENTER,
+ Map.class, null, "segmenter engine and version");
+ if (segmenter == null) {
+ segmenterVersion = null;
+ segmenterBackend = null;
+ resegment = false;
+ } else {
+ segmenterBackend = getMapValue(SEGMENTER, segmenter,
+ SEGMENTER_BACKEND, String.class);
+ try {
+ segmenterVersion = new Version(getMapValue(SEGMENTER,
+ segmenter, SEGMENTER_VERSION, String.class));
+ } catch (RuntimeException e) {
+ segmenterVersion = null;
+ }
+ if (localSegmenterBackend.equals(segmenterBackend)
+ && localSegmenterVersion.equals(segmenterVersion)) {
+ resegment = false;
+ } else {
+ resegment = true;
+ }
+ }
+ }
+
+ private void populateYqlSources(OperatorNode<?> filterArgs) {
+ yqlSources.clear();
+ if (filterArgs.getOperator() == SequenceOperator.SCAN) {
+ for (String source : filterArgs.<List<String>> getArgument(0)) {
+ yqlSources.add(source);
+ }
+ } else if (filterArgs.getOperator() == SequenceOperator.ALL) {
+ // yqlSources has already been cleared
+ } else if (filterArgs.getOperator() == SequenceOperator.MULTISOURCE) {
+ for (List<String> source : filterArgs.<List<List<String>>> getArgument(0)) {
+ yqlSources.add(source.get(0));
+ }
+ } else {
+ throw newUnexpectedArgumentException(filterArgs.getOperator(),
+ SequenceOperator.SCAN, SequenceOperator.ALL,
+ SequenceOperator.MULTISOURCE);
+ }
+ joinDocTypesFromUserQueryAndYql();
+ }
+
+ private void populateYqlSummaryFields(
+ List<OperatorNode<ProjectOperator>> fields) {
+ yqlSummaryFields.clear();
+ for (OperatorNode<ProjectOperator> field : fields) {
+ assertHasOperator(field, ProjectOperator.FIELD);
+ yqlSummaryFields.add(field.getArgument(1, String.class));
+ }
+ }
+
+ private void connectItems() {
+ for (ConnectedItem entry : connectedItems) {
+ TaggableItem to = identifiedItems.get(entry.toId);
+ Preconditions
+ .checkNotNull(to,
+ "Item '%s' was specified to connect to item with ID %s, which does not "
+ + "exist in the query.", entry.fromItem,
+ entry.toId);
+ entry.fromItem.setConnectivity((Item) to, entry.weight);
+ }
+ }
+
+ @NonNull
+ private Item convertExpression(OperatorNode<ExpressionOperator> ast) {
+ try {
+ annotationStack.addFirst(ast);
+ switch (ast.getOperator()) {
+ case AND:
+ return buildAnd(ast);
+ case OR:
+ return buildOr(ast);
+ case EQ:
+ return buildEquals(ast);
+ case LT:
+ return buildLessThan(ast);
+ case GT:
+ return buildGreaterThan(ast);
+ case LTEQ:
+ return buildLessThanOrEquals(ast);
+ case GTEQ:
+ return buildGreaterThanOrEquals(ast);
+ case CONTAINS:
+ return buildTermSearch(ast);
+ case MATCHES:
+ return buildRegExpSearch(ast);
+ case CALL:
+ return buildFunctionCall(ast);
+ default:
+ throw newUnexpectedArgumentException(ast.getOperator(),
+ ExpressionOperator.AND, ExpressionOperator.CALL,
+ ExpressionOperator.CONTAINS, ExpressionOperator.EQ,
+ ExpressionOperator.GT, ExpressionOperator.GTEQ,
+ ExpressionOperator.LT, ExpressionOperator.LTEQ,
+ ExpressionOperator.OR);
+ }
+ } finally {
+ annotationStack.removeFirst();
+ }
+ }
+
+ @NonNull
+ private Item buildFunctionCall(OperatorNode<ExpressionOperator> ast) {
+ List<String> names = ast.getArgument(0);
+ Preconditions.checkArgument(names.size() == 1,
+ "Expected 1 name, got %s.", names.size());
+ switch (names.get(0)) {
+ case USER_QUERY:
+ return fetchUserQuery();
+ case RANGE:
+ return buildRange(ast);
+ case WAND:
+ return buildWand(ast);
+ case WEIGHTED_SET:
+ return buildWeightedSet(ast);
+ case DOT_PRODUCT:
+ return buildDotProduct(ast);
+ case PREDICATE:
+ return buildPredicate(ast);
+ case RANK:
+ return buildRank(ast);
+ case WEAK_AND:
+ return buildWeakAnd(ast);
+ case USER_INPUT:
+ return buildUserInput(ast);
+ case NON_EMPTY:
+ return ensureNonEmpty(ast);
+ default:
+ throw newUnexpectedArgumentException(names.get(0), DOT_PRODUCT,
+ RANGE, RANK, USER_QUERY, WAND, WEAK_AND, WEIGHTED_SET,
+ PREDICATE, USER_INPUT, NON_EMPTY);
+ }
+ }
+
+ private Item ensureNonEmpty(OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() == 1,
+ "Expected 1 arguments, got %s.", args.size());
+ Item item = convertExpression(args.get(0));
+ ToolBox.visit(noEmptyTerms, item);
+ return item;
+ }
+
+ @NonNull
+ private Item buildWeightedSet(OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() == 2,
+ "Expected 2 arguments, got %s.", args.size());
+
+ return fillWeightedSet(ast, args.get(1), new WeightedSetItem(
+ getIndex(args.get(0))));
+ }
+
+ @NonNull
+ private Item buildDotProduct(OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() == 2,
+ "Expected 2 arguments, got %s.", args.size());
+
+ return fillWeightedSet(ast, args.get(1), new DotProductItem(
+ getIndex(args.get(0))));
+ }
+
+ @NonNull
+ private Item buildPredicate(OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() == 3,
+ "Expected 3 arguments, got %s.", args.size());
+
+ final PredicateQueryItem item = new PredicateQueryItem();
+ item.setIndexName(getIndex(args.get(0)));
+
+ addFeatures(args.get(1),
+ (key, value, subqueryBitmap) -> item.addFeature(key, (String) value, subqueryBitmap), PredicateQueryItem.ALL_SUB_QUERIES);
+ addFeatures(args.get(2), (key, value, subqueryBitmap) -> {
+ if (value instanceof Long) {
+ item.addRangeFeature(key, (Long) value, subqueryBitmap);
+ } else {
+ item.addRangeFeature(key, (Integer) value, subqueryBitmap);
+ }
+ }, PredicateQueryItem.ALL_SUB_QUERIES);
+ return leafStyleSettings(ast, item);
+ }
+
+ interface AddFeature {
+ public void addFeature(String key, Object value, long subqueryBitmap);
+ }
+
+ private void addFeatures(OperatorNode<ExpressionOperator> map,
+ AddFeature item, long subqueryBitmap) {
+ if (map.getOperator() != ExpressionOperator.MAP) {
+ return;
+ }
+ assertHasOperator(map, ExpressionOperator.MAP);
+ List<String> keys = map.getArgument(0);
+ List<OperatorNode<ExpressionOperator>> values = map.getArgument(1);
+ for (int i = 0; i < keys.size(); ++i) {
+ String key = keys.get(i);
+ OperatorNode<ExpressionOperator> value = values.get(i);
+ if (value.getOperator() == ExpressionOperator.ARRAY) {
+ List<OperatorNode<ExpressionOperator>> multiValues = value
+ .getArgument(0);
+ for (OperatorNode<ExpressionOperator> multiValue : multiValues) {
+ assertHasOperator(multiValue, ExpressionOperator.LITERAL);
+ item.addFeature(key, multiValue.getArgument(0), subqueryBitmap);
+ }
+ } else if (value.getOperator() == ExpressionOperator.LITERAL) {
+ item.addFeature(key, value.getArgument(0), subqueryBitmap);
+ } else {
+ assertHasOperator(value, ExpressionOperator.MAP); // Subquery syntax
+ Preconditions.checkArgument(key.indexOf("0x") == 0 || key.indexOf("[") == 0);
+ if (key.indexOf("0x") == 0) {
+ String subqueryString = key.substring(2);
+ if (subqueryString.length() > 16) {
+ throw new NumberFormatException(
+ "Too long subquery string: " + key);
+ }
+ long currentSubqueryBitmap = new BigInteger(subqueryString, 16).longValue();
+ addFeatures(value, item, currentSubqueryBitmap);
+ } else {
+ StringTokenizer bits = new StringTokenizer(key.substring(1, key.length() - 1), ",");
+ long currentSubqueryBitmap = 0;
+ while (bits.hasMoreTokens()) {
+ int bit = Integer.parseInt(bits.nextToken().trim());
+ currentSubqueryBitmap |= 1L << bit;
+ }
+ addFeatures(value, item, currentSubqueryBitmap);
+ }
+ }
+ }
+ }
+
+ @NonNull
+ private Item buildWand(OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() == 2, "Expected 2 arguments, got %s.", args.size());
+
+ WandItem out = new WandItem(getIndex(args.get(0)), getAnnotation(ast,
+ TARGET_NUM_HITS, Integer.class, DEFAULT_TARGET_NUM_HITS,
+ "desired number of hits to accumulate in wand"));
+ Double scoreThreshold = getAnnotation(ast, SCORE_THRESHOLD,
+ Double.class, null, "min score for hit inclusion");
+ if (scoreThreshold != null) {
+ out.setScoreThreshold(scoreThreshold);
+ }
+ Double thresholdBoostFactor = getAnnotation(ast,
+ THRESHOLD_BOOST_FACTOR, Double.class, null,
+ "boost factor used to boost threshold before comparing against upper bound score");
+ if (thresholdBoostFactor != null) {
+ out.setThresholdBoostFactor(thresholdBoostFactor);
+ }
+ return fillWeightedSet(ast, args.get(1), out);
+ }
+
+ @NonNull
+ private WeightedSetItem fillWeightedSet(OperatorNode<ExpressionOperator> ast,
+ OperatorNode<ExpressionOperator> arg,
+ @NonNull WeightedSetItem out) {
+ addItems(arg, out);
+ return leafStyleSettings(ast, out);
+ }
+
+ @NonNull
+ private Item instantiatePhraseItem(String field, OperatorNode<ExpressionOperator> ast) {
+ assertHasFunctionName(ast, PHRASE);
+
+ if (getAnnotation(ast, ORIGIN, Map.class, null, ORIGIN_DESCRIPTION, false) != null) {
+ return instantiatePhraseSegmentItem(field, ast, false);
+ }
+
+ PhraseItem phrase = new PhraseItem();
+ phrase.setIndexName(field);
+ for (OperatorNode<ExpressionOperator> word : ast.<List<OperatorNode<ExpressionOperator>>> getArgument(1)) {
+ if (word.getOperator() == ExpressionOperator.CALL) {
+ List<String> names = word.getArgument(0);
+ switch (names.get(0)) {
+ case PHRASE:
+ if (getAnnotation(word, ORIGIN, Map.class, null, ORIGIN_DESCRIPTION, false) == null) {
+ phrase.addItem(instantiatePhraseItem(field, word));
+ } else {
+ phrase.addItem(instantiatePhraseSegmentItem(field, word, true));
+ }
+ break;
+ case ALTERNATIVES:
+ phrase.addItem(instantiateWordAlternativesItem(field, word));
+ break;
+ default:
+ throw new IllegalArgumentException("Expected phrase or word alternatives, got " + names.get(0));
+ }
+ } else {
+ phrase.addItem(instantiateWordItem(field, word, phrase.getClass()));
+ }
+ }
+ return leafStyleSettings(ast, phrase);
+ }
+
+ @NonNull
+ private Item instantiatePhraseSegmentItem(String field, OperatorNode<ExpressionOperator> ast, boolean forcePhrase) {
+ Substring origin = getOrigin(ast);
+ Boolean stem = getAnnotation(ast, STEM, Boolean.class, Boolean.TRUE, STEM_DESCRIPTION);
+ Boolean andSegmenting = getAnnotation(ast, AND_SEGMENTING, Boolean.class, Boolean.FALSE,
+ "setting for whether to force using AND for segments on and off");
+ SegmentItem phrase;
+ List<String> words = null;
+
+ if (forcePhrase || !andSegmenting) {
+ phrase = new PhraseSegmentItem(origin.getValue(), origin.getValue(), true, !stem, origin);
+ } else {
+ phrase = new AndSegmentItem(origin.getValue(), true, !stem);
+ }
+ phrase.setIndexName(field);
+
+ if (resegment
+ && getAnnotation(ast, IMPLICIT_TRANSFORMS, Boolean.class, Boolean.TRUE, IMPLICIT_TRANSFORMS_DESCRIPTION)) {
+ words = segmenter.segment(origin.getValue(), currentlyParsing.getLanguage());
+ }
+
+ if (words != null && words.size() > 0) {
+ for (String word : words) {
+ phrase.addItem(new WordItem(word, field, true));
+ }
+ } else {
+ for (OperatorNode<ExpressionOperator> word : ast.<List<OperatorNode<ExpressionOperator>>> getArgument(1)) {
+ phrase.addItem(instantiateWordItem(field, word, phrase.getClass(), SegmentWhen.NEVER));
+ }
+ }
+ if (phrase instanceof TaggableItem) {
+ leafStyleSettings(ast, (TaggableItem) phrase);
+ }
+ phrase.lock();
+ return phrase;
+ }
+
+ @NonNull
+ private Item instantiateNearItem(String field, OperatorNode<ExpressionOperator> ast) {
+ assertHasFunctionName(ast, NEAR);
+
+ NearItem near = new NearItem();
+ near.setIndexName(field);
+ for (OperatorNode<ExpressionOperator> word : ast.<List<OperatorNode<ExpressionOperator>>> getArgument(1)) {
+ near.addItem(instantiateWordItem(field, word, near.getClass()));
+ }
+ Integer distance = getAnnotation(ast, DISTANCE, Integer.class, null, "term distance for NEAR operator");
+ if (distance != null) {
+ near.setDistance(distance);
+ }
+ return near;
+ }
+
+ @NonNull
+ private Item instantiateONearItem(String field, OperatorNode<ExpressionOperator> ast) {
+ assertHasFunctionName(ast, ONEAR);
+
+ NearItem onear = new ONearItem();
+ onear.setIndexName(field);
+ for (OperatorNode<ExpressionOperator> word : ast.<List<OperatorNode<ExpressionOperator>>> getArgument(1)) {
+ onear.addItem(instantiateWordItem(field, word, onear.getClass()));
+ }
+ Integer distance = getAnnotation(ast, DISTANCE, Integer.class, null, "term distance for ONEAR operator");
+ if (distance != null) {
+ onear.setDistance(distance);
+ }
+ return onear;
+ }
+
+ @NonNull
+ private Item fetchUserQuery() {
+ Preconditions.checkState(!queryParser,
+ "Tried inserting user query into itself.");
+ Preconditions.checkState(userQuery != null,
+ "User query must be set before trying to build complete query "
+ + "tree including user query.");
+ return userQuery.getModel().getQueryTree().getRoot();
+ }
+
+ @NonNull
+ private Item buildUserInput(OperatorNode<ExpressionOperator> ast) {
+
+ String grammar = getAnnotation(ast, USER_INPUT_GRAMMAR, String.class,
+ Query.Type.ALL.toString(), "grammar for handling user input");
+ String defaultIndex = getAnnotation(ast, USER_INPUT_DEFAULT_INDEX,
+ String.class, "default", "default index for user input terms");
+ Boolean allowEmpty = getAnnotation(ast, USER_INPUT_ALLOW_EMPTY, Boolean.class,
+ Boolean.FALSE, "flag for allowing NullItem to be returned");
+ String wordData;
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+
+ // TODO add support for default arguments if property results in nothing
+ wordData = getStringContents(args.get(0));
+ if (allowEmpty.booleanValue() && (wordData == null || wordData.isEmpty())) {
+ return new NullItem();
+ }
+ String languageTag = getAnnotation(ast, USER_INPUT_LANGUAGE,
+ String.class, "en",
+ "language setting for segmenting user input parameter");
+ Language language = Language.fromLanguageTag(languageTag);
+ Item item;
+ if (USER_INPUT_RAW.equals(grammar)) {
+ item = instantiateWordItem(defaultIndex, wordData, ast, null, SegmentWhen.NEVER,
+ language);
+ } else if (USER_INPUT_SEGMENT.equals(grammar)) {
+ item = instantiateWordItem(defaultIndex, wordData, ast, null,
+ SegmentWhen.ALWAYS, language);
+ } else {
+ item = parseUserInput(grammar, defaultIndex, wordData, language, allowEmpty.booleanValue());
+ propagateUserInputAnnotations(ast, item);
+ }
+ return item;
+ }
+
+ private String getStringContents(
+ OperatorNode<ExpressionOperator> propertySniffer) {
+ String wordData;
+
+ switch (propertySniffer.getOperator()) {
+ case LITERAL:
+ wordData = propertySniffer.getArgument(0, String.class);
+ break;
+ case VARREF:
+ Preconditions
+ .checkState(userQuery != null,
+ "properties must be available when trying to fetch user input");
+ wordData = userQuery.properties().getString(
+ propertySniffer.getArgument(0, String.class));
+ break;
+ default:
+ throw newUnexpectedArgumentException(propertySniffer.getOperator(),
+ ExpressionOperator.LITERAL, ExpressionOperator.VARREF);
+ }
+ return wordData;
+ }
+
+ private class AnnotationPropagator extends QueryVisitor {
+ private final Boolean isRanked;
+ private final Boolean filter;
+ private final Boolean stem;
+ private final Boolean normalizeCase;
+ private final Boolean accentDrop;
+ private final Boolean usePositionData;
+
+ public AnnotationPropagator(OperatorNode<ExpressionOperator> ast) {
+ isRanked = getAnnotation(ast, RANKED, Boolean.class, null,
+ RANKED_DESCRIPTION);
+ filter = getAnnotation(ast, FILTER, Boolean.class, null,
+ FILTER_DESCRIPTION);
+ stem = getAnnotation(ast, STEM, Boolean.class, null,
+ STEM_DESCRIPTION);
+ normalizeCase = getAnnotation(ast, NORMALIZE_CASE, Boolean.class,
+ Boolean.TRUE, NORMALIZE_CASE_DESCRIPTION);
+ accentDrop = getAnnotation(ast, ACCENT_DROP, Boolean.class, null,
+ ACCENT_DROP_DESCRIPTION);
+ usePositionData = getAnnotation(ast, USE_POSITION_DATA,
+ Boolean.class, null, USE_POSITION_DATA_DESCRIPTION);
+ }
+
+ @Override
+ public boolean visit(Item item) {
+ if (item instanceof WordItem) {
+ WordItem w = (WordItem) item;
+ if (usePositionData != null) {
+ w.setPositionData(usePositionData);
+ }
+ if (stem != null) {
+ w.setStemmed(!stem);
+ }
+ if (normalizeCase != null) {
+ w.setLowercased(!normalizeCase);
+ }
+ if (accentDrop != null) {
+ w.setNormalizable(accentDrop);
+ }
+ }
+ if (item instanceof TaggableItem) {
+ if (isRanked != null) {
+ item.setRanked(isRanked);
+ }
+ if (filter != null) {
+ item.setFilter(filter);
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void onExit() {
+ // intentionally left blank
+ }
+ }
+
+ private void propagateUserInputAnnotations(
+ OperatorNode<ExpressionOperator> ast, Item item) {
+ ToolBox.visit(new AnnotationPropagator(ast), item);
+
+ }
+
+ @NonNull
+ private Item parseUserInput(String grammar, String defaultIndex, String wordData,
+ Language language, boolean allowNullItem) {
+ Item item;
+ Query.Type parseAs = Query.Type.getType(grammar);
+ Parser parser = ParserFactory.newInstance(parseAs, environment);
+ // perhaps not use already resolved doctypes, but respect source and
+ // restrict
+ item = parser.parse(
+ new Parsable().setQuery(wordData).addSources(docTypes)
+ .setLanguage(language)
+ .setDefaultIndexName(defaultIndex)).getRoot();
+ // the null check should be unnecessary, but is there to avoid having to
+ // suppress null warnings
+ if (!allowNullItem && (item == null || item instanceof NullItem)) {
+ throw new IllegalArgumentException("Parsing \"" + wordData
+ + "\" only resulted in NullItem.");
+ }
+ return item;
+ }
+
+ @NonNull
+ private OperatorNode<?> fetchFilterPart() {
+ ProgramParser parser = new ProgramParser();
+ OperatorNode<?> ast;
+ try {
+ ast = parser.parse("query", currentlyParsing.getQuery());
+ } catch (Exception e) {
+ throw new IllegalArgumentException(e);
+ }
+ assertHasOperator(ast, StatementOperator.PROGRAM);
+ Preconditions.checkArgument(ast.getArguments().length == 1,
+ "Expected only a single argument to the root node, got %s.",
+ ast.getArguments().length);
+ // TODO: should we check size of first argument as well?
+ ast = ast.<List<OperatorNode<?>>> getArgument(0).get(0);
+ assertHasOperator(ast, StatementOperator.EXECUTE);
+
+ ast = ast.getArgument(0);
+ ast = fetchTimeout(ast);
+ ast = fetchPipe(ast);
+ ast = fetchSummaryFields(ast);
+ ast = fetchOffsetAndHits(ast);
+ ast = fetchSorting(ast);
+ assertHasOperator(ast, SequenceOperator.FILTER);
+ return ast;
+ }
+
+ @SuppressWarnings("unchecked")
+ private OperatorNode<?> fetchPipe(OperatorNode<?> toScan) {
+ OperatorNode<?> ast = toScan;
+ while (ast.getOperator() == SequenceOperator.PIPE) {
+ OperatorNode<ExpressionOperator> groupingAst = ast
+ .<List<OperatorNode<ExpressionOperator>>> getArgument(2)
+ .get(0);
+ GroupingOperation groupingOperation = GroupingOperation
+ .fromString(groupingAst.<String> getArgument(0));
+ VespaGroupingStep groupingStep = new VespaGroupingStep(
+ groupingOperation);
+ List<String> continuations = getAnnotation(groupingAst,
+ "continuations", List.class, Collections.emptyList(),
+ "grouping continuations");
+ for (String continuation : continuations) {
+ groupingStep.continuations().add(
+ Continuation.fromString(continuation));
+ }
+ groupingSteps.add(groupingStep);
+ ast = ast.getArgument(0);
+ }
+ Collections.reverse(groupingSteps);
+ return ast;
+ }
+
+ @NonNull
+ private OperatorNode<?> fetchSorting(OperatorNode<?> ast) {
+ if (ast.getOperator() != SequenceOperator.SORT) {
+ return ast;
+ }
+ List<FieldOrder> sortingInit = new ArrayList<>();
+ List<OperatorNode<?>> sortArguments = ast.getArgument(1);
+ for (OperatorNode<?> op : sortArguments) {
+ final OperatorNode<ExpressionOperator> fieldNode = op
+ .<OperatorNode<ExpressionOperator>> getArgument(0);
+ String field = fetchFieldRead(fieldNode);
+ String locale = getAnnotation(fieldNode, SORTING_LOCALE,
+ String.class, null, "locale used by sorting function");
+ String function = getAnnotation(fieldNode, SORTING_FUNCTION,
+ String.class, null,
+ "sorting function for the specified attribute");
+ String strength = getAnnotation(fieldNode, SORTING_STRENGTH,
+ String.class, null, "strength for sorting function");
+ AttributeSorter sorter;
+ if (function == null) {
+ sorter = new AttributeSorter(field);
+ } else if (Sorting.LOWERCASE.equals(function)) {
+ sorter = new LowerCaseSorter(field);
+ } else if (Sorting.RAW.equals(function)) {
+ sorter = new RawSorter(field);
+ } else if (Sorting.UCA.equals(function)) {
+ if (locale != null) {
+ UcaSorter.Strength ucaStrength = UcaSorter.Strength.UNDEFINED;
+ if (strength != null) {
+ if (Sorting.STRENGTH_PRIMARY.equalsIgnoreCase(strength)) {
+ ucaStrength = UcaSorter.Strength.PRIMARY;
+ } else if (Sorting.STRENGTH_SECONDARY
+ .equalsIgnoreCase(strength)) {
+ ucaStrength = UcaSorter.Strength.SECONDARY;
+ } else if (Sorting.STRENGTH_TERTIARY
+ .equalsIgnoreCase(strength)) {
+ ucaStrength = UcaSorter.Strength.TERTIARY;
+ } else if (Sorting.STRENGTH_QUATERNARY
+ .equalsIgnoreCase(strength)) {
+ ucaStrength = UcaSorter.Strength.QUATERNARY;
+ } else if (Sorting.STRENGTH_IDENTICAL
+ .equalsIgnoreCase(strength)) {
+ ucaStrength = UcaSorter.Strength.IDENTICAL;
+ } else {
+ throw newUnexpectedArgumentException(function,
+ Sorting.STRENGTH_PRIMARY,
+ Sorting.STRENGTH_SECONDARY,
+ Sorting.STRENGTH_TERTIARY,
+ Sorting.STRENGTH_QUATERNARY,
+ Sorting.STRENGTH_IDENTICAL);
+ }
+ sorter = new UcaSorter(field, locale, ucaStrength);
+ } else {
+ sorter = new UcaSorter(field, locale, ucaStrength);
+ }
+ } else {
+ sorter = new UcaSorter(field);
+ }
+ } else {
+ throw newUnexpectedArgumentException(function, "lowercase",
+ "raw", "uca");
+ }
+ switch ((SortOperator) op.getOperator()) {
+ case ASC:
+ sortingInit.add(new FieldOrder(sorter, Order.ASCENDING));
+ break;
+ case DESC:
+ sortingInit.add(new FieldOrder(sorter, Order.DESCENDING));
+ break;
+ default:
+ throw newUnexpectedArgumentException(op.getOperator(),
+ SortOperator.ASC, SortOperator.DESC);
+ }
+ }
+ sorting = new Sorting(sortingInit);
+ return ast.getArgument(0);
+ }
+
+ @NonNull
+ private OperatorNode<?> fetchOffsetAndHits(OperatorNode<?> ast) {
+ if (ast.getOperator() == SequenceOperator.OFFSET) {
+ offset = ast.<OperatorNode<?>> getArgument(1)
+ .<Integer> getArgument(0);
+ hits = DEFAULT_HITS;
+ return ast.getArgument(0);
+ }
+ if (ast.getOperator() == SequenceOperator.SLICE) {
+ offset = ast.<OperatorNode<?>> getArgument(1)
+ .<Integer> getArgument(0);
+ hits = ast.<OperatorNode<?>> getArgument(2)
+ .<Integer> getArgument(0) - offset;
+ return ast.getArgument(0);
+ }
+ if (ast.getOperator() == SequenceOperator.LIMIT) {
+ hits = ast.<OperatorNode<?>> getArgument(1)
+ .<Integer> getArgument(0);
+ offset = DEFAULT_OFFSET;
+ return ast.getArgument(0);
+ }
+ return ast;
+ }
+
+ @NonNull
+ private OperatorNode<?> fetchSummaryFields(OperatorNode<?> ast) {
+ if (ast.getOperator() != SequenceOperator.PROJECT) {
+ return ast;
+ }
+ Preconditions.checkArgument(ast.getArguments().length == 2,
+ "Expected 2 arguments to PROJECT, got %s.",
+ ast.getArguments().length);
+ populateYqlSummaryFields(ast
+ .<List<OperatorNode<ProjectOperator>>> getArgument(1));
+ return ast.getArgument(0);
+ }
+
+ private OperatorNode<?> fetchTimeout(OperatorNode<?> ast) {
+ if (ast.getOperator() != SequenceOperator.TIMEOUT) {
+ return ast;
+ }
+ timeout = ast.<OperatorNode<?>> getArgument(1).<Integer> getArgument(0);
+ return ast.getArgument(0);
+ }
+
+ @NonNull
+ private static String fetchFieldRead(OperatorNode<ExpressionOperator> ast) {
+ assertHasOperator(ast, ExpressionOperator.READ_FIELD);
+ return ast.getArgument(1);
+ }
+
+ @NonNull
+ private IntItem buildGreaterThanOrEquals(
+ OperatorNode<ExpressionOperator> ast) {
+ IntItem number;
+ if (isIndexOnLeftHandSide(ast)) {
+ number = new IntItem("[" + fetchConditionWord(ast) + ";]",
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(1, OperatorNode.class),
+ number);
+ } else {
+ number = new IntItem("[;" + fetchConditionWord(ast) + "]",
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(0, OperatorNode.class),
+ number);
+ }
+ return number;
+ }
+
+ @NonNull
+ private IntItem buildLessThanOrEquals(OperatorNode<ExpressionOperator> ast) {
+ IntItem number;
+ if (isIndexOnLeftHandSide(ast)) {
+ number = new IntItem("[;" + fetchConditionWord(ast) + "]",
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(1, OperatorNode.class),
+ number);
+ } else {
+ number = new IntItem("[" + fetchConditionWord(ast) + ";]",
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(0, OperatorNode.class),
+ number);
+ }
+ return number;
+ }
+
+ @NonNull
+ private IntItem buildGreaterThan(OperatorNode<ExpressionOperator> ast) {
+ IntItem number;
+ if (isIndexOnLeftHandSide(ast)) {
+ number = new IntItem(">" + fetchConditionWord(ast),
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(1, OperatorNode.class),
+ number);
+ } else {
+ number = new IntItem("<" + fetchConditionWord(ast),
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(0, OperatorNode.class),
+ number);
+ }
+ return number;
+ }
+
+ @NonNull
+ private IntItem buildLessThan(OperatorNode<ExpressionOperator> ast) {
+ IntItem number;
+ if (isIndexOnLeftHandSide(ast)) {
+ number = new IntItem("<" + fetchConditionWord(ast),
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(1, OperatorNode.class),
+ number);
+ } else {
+ number = new IntItem(">" + fetchConditionWord(ast),
+ fetchConditionIndex(ast));
+ number = leafStyleSettings(ast.getArgument(0, OperatorNode.class),
+ number);
+ }
+ return number;
+ }
+
+ @NonNull
+ private IntItem buildEquals(OperatorNode<ExpressionOperator> ast) {
+ IntItem number = new IntItem(fetchConditionWord(ast),
+ fetchConditionIndex(ast));
+ if (isIndexOnLeftHandSide(ast)) {
+ number = leafStyleSettings(ast.getArgument(1, OperatorNode.class),
+ number);
+ } else {
+ number = leafStyleSettings(ast.getArgument(0, OperatorNode.class),
+ number);
+ }
+ return number;
+ }
+
+ @NonNull
+ private String fetchConditionIndex(OperatorNode<ExpressionOperator> ast) {
+ OperatorNode<ExpressionOperator> lhs = ast.getArgument(0);
+ OperatorNode<ExpressionOperator> rhs = ast.getArgument(1);
+ if (lhs.getOperator() == ExpressionOperator.LITERAL
+ || lhs.getOperator() == ExpressionOperator.NEGATE) {
+ assertHasOperator(rhs, ExpressionOperator.READ_FIELD);
+ return getIndex(rhs);
+ }
+ if (rhs.getOperator() == ExpressionOperator.LITERAL
+ || rhs.getOperator() == ExpressionOperator.NEGATE) {
+ assertHasOperator(lhs, ExpressionOperator.READ_FIELD);
+ return getIndex(lhs);
+ }
+ throw new IllegalArgumentException(
+ "Expected LITERAL and READ_FIELD, got " + lhs.getOperator()
+ + " and " + rhs.getOperator() + ".");
+ }
+
+ private static String getNumberAsString(OperatorNode<ExpressionOperator> ast) {
+ String negative = "";
+ OperatorNode<ExpressionOperator> currentAst = ast;
+ if (currentAst.getOperator() == ExpressionOperator.NEGATE) {
+ negative = "-";
+ currentAst = currentAst.getArgument(0);
+ }
+ assertHasOperator(currentAst, ExpressionOperator.LITERAL);
+ return negative + currentAst.getArgument(0).toString();
+ }
+
+ @NonNull
+ private static String fetchConditionWord(
+ OperatorNode<ExpressionOperator> ast) {
+ OperatorNode<ExpressionOperator> lhs = ast.getArgument(0);
+ OperatorNode<ExpressionOperator> rhs = ast.getArgument(1);
+ if (lhs.getOperator() == ExpressionOperator.LITERAL
+ || lhs.getOperator() == ExpressionOperator.NEGATE) {
+ assertHasOperator(rhs, ExpressionOperator.READ_FIELD);
+ return getNumberAsString(lhs);
+ }
+ if (rhs.getOperator() == ExpressionOperator.LITERAL
+ || rhs.getOperator() == ExpressionOperator.NEGATE) {
+ assertHasOperator(lhs, ExpressionOperator.READ_FIELD);
+ return getNumberAsString(rhs);
+ }
+ throw new IllegalArgumentException(
+ "Expected LITERAL/NEGATE and READ_FIELD, got "
+ + lhs.getOperator() + " and " + rhs.getOperator() + ".");
+ }
+
+ private static boolean isIndexOnLeftHandSide(
+ OperatorNode<ExpressionOperator> ast) {
+ return ast.getArgument(0, OperatorNode.class).getOperator() == ExpressionOperator.READ_FIELD;
+ }
+
+ @NonNull
+ private CompositeItem buildAnd(OperatorNode<ExpressionOperator> ast) {
+ AndItem andItem = new AndItem();
+ NotItem notItem = new NotItem();
+ convertVarArgsAnd(ast, 0, andItem, notItem);
+ Preconditions
+ .checkArgument(andItem.getItemCount() > 0,
+ "Vespa does not support AND with no logically positive branches.");
+ if (notItem.getItemCount() == 0) {
+ return andItem;
+ }
+ if (andItem.getItemCount() == 1) {
+ notItem.setPositiveItem(andItem.getItem(0));
+ } else {
+ notItem.setPositiveItem(andItem);
+ }
+ return notItem;
+ }
+
+ @NonNull
+ private CompositeItem buildOr(OperatorNode<ExpressionOperator> spec) {
+ return convertVarArgs(spec, 0, new OrItem());
+ }
+
+ @NonNull
+ private CompositeItem buildWeakAnd(OperatorNode<ExpressionOperator> spec) {
+ WeakAndItem weakAnd = new WeakAndItem();
+ Integer targetNumHits = getAnnotation(spec, TARGET_NUM_HITS,
+ Integer.class, null, "desired minimum hits to produce");
+ if (targetNumHits != null) {
+ weakAnd.setN(targetNumHits);
+ }
+ Integer scoreThreshold = getAnnotation(spec, SCORE_THRESHOLD,
+ Integer.class, null, "min dot product score for hit inclusion");
+ if (scoreThreshold != null) {
+ weakAnd.setScoreThreshold(scoreThreshold);
+ }
+ return convertVarArgs(spec, 1, weakAnd);
+ }
+
+ @NonNull
+ private CompositeItem buildRank(OperatorNode<ExpressionOperator> spec) {
+ return convertVarArgs(spec, 1, new RankItem());
+ }
+
+ @NonNull
+ private CompositeItem convertVarArgs(OperatorNode<ExpressionOperator> ast,
+ int argIdx, @NonNull
+ CompositeItem out) {
+ Iterable<OperatorNode<ExpressionOperator>> args = ast
+ .getArgument(argIdx);
+ for (OperatorNode<ExpressionOperator> arg : args) {
+ assertHasOperator(arg, ExpressionOperator.class);
+ out.addItem(convertExpression(arg));
+ }
+ return out;
+ }
+
+ private void convertVarArgsAnd(OperatorNode<ExpressionOperator> ast,
+ int argIdx, AndItem outAnd, NotItem outNot) {
+ Iterable<OperatorNode<ExpressionOperator>> args = ast
+ .getArgument(argIdx);
+ for (OperatorNode<ExpressionOperator> arg : args) {
+ assertHasOperator(arg, ExpressionOperator.class);
+ if (arg.getOperator() == ExpressionOperator.NOT) {
+ OperatorNode<ExpressionOperator> exp = arg.getArgument(0);
+ assertHasOperator(exp, ExpressionOperator.class);
+ outNot.addNegativeItem(convertExpression(exp));
+ } else {
+ outAnd.addItem(convertExpression(arg));
+ }
+ }
+ }
+
+ @NonNull
+ private Item buildTermSearch(OperatorNode<ExpressionOperator> ast) {
+ assertHasOperator(ast, ExpressionOperator.CONTAINS);
+ return instantiateLeafItem(
+ getIndex(ast.<OperatorNode<ExpressionOperator>> getArgument(0)),
+ ast.<OperatorNode<ExpressionOperator>> getArgument(1));
+ }
+
+ @NonNull
+ private Item buildRegExpSearch(OperatorNode<ExpressionOperator> ast) {
+ assertHasOperator(ast, ExpressionOperator.MATCHES);
+ String field = getIndex(ast.<OperatorNode<ExpressionOperator>> getArgument(0));
+ OperatorNode<ExpressionOperator> ast1 = ast.<OperatorNode<ExpressionOperator>> getArgument(1);
+ String wordData = getStringContents(ast1);
+ RegExpItem regExp = new RegExpItem(field, true, wordData);
+ return leafStyleSettings(ast1, regExp);
+ }
+
+
+ @NonNull
+ private Item buildRange(OperatorNode<ExpressionOperator> spec) {
+ assertHasOperator(spec, ExpressionOperator.CALL);
+ assertHasFunctionName(spec, RANGE);
+
+ IntItem range = instantiateRangeItem(
+ spec.<List<OperatorNode<ExpressionOperator>>> getArgument(1),
+ spec);
+ return leafStyleSettings(spec, range);
+ }
+
+ private static Number negate(Number x) {
+ if (x.getClass() == Integer.class) {
+ int x1 = x.intValue();
+ return Integer.valueOf(-x1);
+ } else if (x.getClass() == Long.class) {
+ long x1 = x.longValue();
+ return Long.valueOf(-x1);
+ } else if (x.getClass() == Float.class) {
+ float x1 = x.floatValue();
+ return Float.valueOf(-x1);
+ } else if (x.getClass() == Double.class) {
+ double x1 = x.doubleValue();
+ return Double.valueOf(-x1);
+ } else {
+ throw newUnexpectedArgumentException(x.getClass(), Integer.class,
+ Long.class, Float.class, Double.class);
+ }
+ }
+
+ @NonNull
+ private IntItem instantiateRangeItem(
+ List<OperatorNode<ExpressionOperator>> args,
+ OperatorNode<ExpressionOperator> spec) {
+ Preconditions.checkArgument(args.size() == 3,
+ "Expected 3 arguments, got %s.", args.size());
+
+ Number lowerArg = getBound(args.get(1));
+ Number upperArg = getBound(args.get(2));
+ String bounds = getAnnotation(spec, BOUNDS, String.class, null,
+ "whether bounds should be open or closed");
+ // TODO: add support for implicit transforms
+ if (bounds == null) {
+ return new RangeItem(lowerArg, upperArg, getIndex(args.get(0)));
+ } else {
+ Limit from;
+ Limit to;
+ if (BOUNDS_OPEN.equals(bounds)) {
+ from = new Limit(lowerArg, false);
+ to = new Limit(upperArg, false);
+ } else if (BOUNDS_LEFT_OPEN.equals(bounds)) {
+ from = new Limit(lowerArg, false);
+ to = new Limit(upperArg, true);
+ } else if (BOUNDS_RIGHT_OPEN.equals(bounds)) {
+ from = new Limit(lowerArg, true);
+ to = new Limit(upperArg, false);
+ } else {
+ throw newUnexpectedArgumentException(bounds, BOUNDS_OPEN,
+ BOUNDS_LEFT_OPEN, BOUNDS_RIGHT_OPEN);
+ }
+ return new IntItem(from, to, getIndex(args.get(0)));
+ }
+ }
+
+ private Number getBound(OperatorNode<ExpressionOperator> bound) {
+ Number boundValue;
+ OperatorNode<ExpressionOperator> currentBound = bound;
+ boolean negate = false;
+ if (currentBound.getOperator() == ExpressionOperator.NEGATE) {
+ currentBound = currentBound.getArgument(0);
+ negate = true;
+ }
+ assertHasOperator(currentBound, ExpressionOperator.LITERAL);
+ boundValue = currentBound.getArgument(0, Number.class);
+ if (negate) {
+ boundValue = negate(boundValue);
+ }
+ return boundValue;
+ }
+
+ @NonNull
+ private Item instantiateLeafItem(String field,
+ OperatorNode<ExpressionOperator> ast) {
+ switch (ast.getOperator()) {
+ case LITERAL:
+ case VARREF:
+ return instantiateWordItem(field, ast, null);
+ case CALL:
+ return instantiateCompositeLeaf(field, ast);
+ default:
+ throw newUnexpectedArgumentException(ast.getOperator().name(),
+ ExpressionOperator.CALL, ExpressionOperator.LITERAL);
+ }
+ }
+
+ @NonNull
+ private Item instantiateCompositeLeaf(String field,
+ OperatorNode<ExpressionOperator> ast) {
+ List<String> names = ast.getArgument(0);
+ Preconditions.checkArgument(names.size() == 1,
+ "Expected 1 name, got %s.", names.size());
+ switch (names.get(0)) {
+ case PHRASE:
+ return instantiatePhraseItem(field, ast);
+ case NEAR:
+ return instantiateNearItem(field, ast);
+ case ONEAR:
+ return instantiateONearItem(field, ast);
+ case EQUIV:
+ return instantiateEquivItem(field, ast);
+ case ALTERNATIVES:
+ return instantiateWordAlternativesItem(field, ast);
+ default:
+ throw newUnexpectedArgumentException(names.get(0), EQUIV, NEAR,
+ ONEAR, PHRASE);
+ }
+ }
+
+ private Item instantiateWordAlternativesItem(String field, OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() >= 1, "Expected 1 or more arguments, got %s.", args.size());
+ Preconditions.checkArgument(args.get(0).getOperator() == ExpressionOperator.MAP, "Expected MAP, got %s.", args.get(0)
+ .getOperator());
+
+ List<WordAlternativesItem.Alternative> terms = new ArrayList<>();
+ List<String> keys = args.get(0).getArgument(0);
+ List<OperatorNode<ExpressionOperator>> values = args.get(0).getArgument(1);
+ for (int i = 0; i < keys.size(); ++i) {
+ String term = keys.get(i);
+ double exactness;
+ OperatorNode<ExpressionOperator> value = values.get(i);
+ switch (value.getOperator()) {
+ case LITERAL:
+ exactness = value.getArgument(0, Double.class);
+ break;
+ default:
+ throw newUnexpectedArgumentException(value.getOperator(), ExpressionOperator.LITERAL);
+ }
+ terms.add(new WordAlternativesItem.Alternative(term, exactness));
+ }
+ Substring origin = getOrigin(ast);
+ final Boolean isFromQuery = getAnnotation(ast, IMPLICIT_TRANSFORMS, Boolean.class, Boolean.TRUE,
+ IMPLICIT_TRANSFORMS_DESCRIPTION);
+ return leafStyleSettings(ast, new WordAlternativesItem(field, isFromQuery, origin, terms));
+ }
+
+ @NonNull
+ private Item instantiateEquivItem(String field,
+ OperatorNode<ExpressionOperator> ast) {
+ List<OperatorNode<ExpressionOperator>> args = ast.getArgument(1);
+ Preconditions.checkArgument(args.size() >= 2,
+ "Expected 2 or more arguments, got %s.", args.size());
+
+ EquivItem equiv = new EquivItem();
+ equiv.setIndexName(field);
+ for (OperatorNode<ExpressionOperator> arg : args) {
+ switch (arg.getOperator()) {
+ case LITERAL:
+ equiv.addItem(instantiateWordItem(field, arg, equiv.getClass()));
+ break;
+ case CALL:
+ assertHasFunctionName(arg, PHRASE);
+ equiv.addItem(instantiatePhraseItem(field, arg));
+ break;
+ default:
+ throw newUnexpectedArgumentException(arg.getOperator(),
+ ExpressionOperator.CALL, ExpressionOperator.LITERAL);
+ }
+ }
+ return leafStyleSettings(ast, equiv);
+ }
+
+ @NonNull
+ private Item instantiateWordItem(String field,
+ OperatorNode<ExpressionOperator> ast, Class<?> parent) {
+ return instantiateWordItem(field, ast, parent, SegmentWhen.POSSIBLY);
+ }
+
+ @NonNull
+ private Item instantiateWordItem(String field,
+ OperatorNode<ExpressionOperator> ast, Class<?> parent,
+ SegmentWhen segmentPolicy) {
+ String wordData = getStringContents(ast);
+ return instantiateWordItem(field, wordData, ast, parent,
+ segmentPolicy, null);
+ }
+
+ @NonNull
+ private Item instantiateWordItem(String field,
+ String rawWord,
+ OperatorNode<ExpressionOperator> ast, Class<?> parent,
+ SegmentWhen segmentPolicy, Language language) {
+ String wordData = rawWord;
+ if (getAnnotation(ast, NFKC, Boolean.class, Boolean.TRUE,
+ "setting for whether to NFKC normalize input data")) {
+ wordData = normalizer.normalize(wordData);
+ }
+ boolean fromQuery = getAnnotation(ast, IMPLICIT_TRANSFORMS,
+ Boolean.class, Boolean.TRUE, IMPLICIT_TRANSFORMS_DESCRIPTION);
+ boolean prefixMatch = getAnnotation(ast, PREFIX, Boolean.class,
+ Boolean.FALSE,
+ "setting for whether to use prefix match of input data");
+ boolean suffixMatch = getAnnotation(ast, SUFFIX, Boolean.class,
+ Boolean.FALSE,
+ "setting for whether to use suffix match of input data");
+ boolean substrMatch = getAnnotation(ast, SUBSTRING, Boolean.class,
+ Boolean.FALSE,
+ "setting for whether to use substring match of input data");
+ Preconditions.checkArgument((prefixMatch ? 1 : 0)
+ + (substrMatch ? 1 : 0) + (suffixMatch ? 1 : 0) < 2,
+ "Only one of prefix, substring and suffix can be set.");
+ @NonNull
+ final TaggableItem wordItem;
+
+ if (prefixMatch) {
+ wordItem = new PrefixItem(wordData, fromQuery);
+ } else if (suffixMatch) {
+ wordItem = new SuffixItem(wordData, fromQuery);
+ } else if (substrMatch) {
+ wordItem = new SubstringItem(wordData, fromQuery);
+ } else {
+ switch (segmentPolicy) {
+ case NEVER:
+ wordItem = new WordItem(wordData, fromQuery);
+ break;
+ case POSSIBLY:
+ if (shouldResegmentWord(field, fromQuery)) {
+ wordItem = resegment(field, ast, wordData, fromQuery,
+ parent, language);
+ } else {
+ wordItem = new WordItem(wordData, fromQuery);
+ }
+ break;
+ case ALWAYS:
+ wordItem = resegment(field, ast, wordData, fromQuery, parent,
+ language);
+ break;
+ default:
+ throw new IllegalArgumentException(
+ "Unexpected segmenting rule: " + segmentPolicy);
+ }
+ }
+ if (wordItem instanceof WordItem) {
+ prepareWord(field, ast, fromQuery, (WordItem) wordItem);
+ }
+ return (Item) leafStyleSettings(ast, wordItem);
+ }
+
+ @SuppressWarnings({"deprecation"})
+ private boolean shouldResegmentWord(String field, boolean fromQuery) {
+ return resegment && fromQuery && ! indexFactsSession.getIndex(field).isAttribute();
+ }
+
+ @NonNull
+ private TaggableItem resegment(String field,
+ OperatorNode<ExpressionOperator> ast, String wordData,
+ boolean fromQuery, Class<?> parent, Language language) {
+ final TaggableItem wordItem;
+ String toSegment = wordData;
+ final Substring s = getOrigin(ast);
+ final Language usedLanguage = language == null ? currentlyParsing.getLanguage() : language;
+ if (s != null) {
+ toSegment = s.getValue();
+ }
+ List<String> words = segmenter.segment(toSegment,
+ usedLanguage);
+ if (words.size() == 0) {
+ wordItem = new WordItem(wordData, fromQuery);
+ } else if (words.size() == 1 || !phraseArgumentSupported(parent)) {
+ wordItem = new WordItem(words.get(0), fromQuery);
+ } else {
+ wordItem = new PhraseSegmentItem(toSegment, fromQuery, false);
+ ((PhraseSegmentItem) wordItem).setIndexName(field);
+ for (String w : words) {
+ WordItem segment = new WordItem(w, fromQuery);
+ prepareWord(field, ast, fromQuery, segment);
+ ((PhraseSegmentItem) wordItem).addItem(segment);
+ }
+ ((PhraseSegmentItem) wordItem).lock();
+ }
+ return wordItem;
+ }
+
+ private boolean phraseArgumentSupported(Class<?> parent) {
+ if (parent == null) {
+ return true;
+ } else if (parent == PhraseItem.class) {
+ // not supported in backend, but the container flattens the
+ // arguments itself
+ return true;
+ } else if (parent == EquivItem.class) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private void prepareWord(String field,
+ OperatorNode<ExpressionOperator> ast, boolean fromQuery,
+ WordItem wordItem) {
+ wordItem.setIndexName(field);
+ wordStyleSettings(ast, wordItem);
+ if (shouldResegmentWord(field, fromQuery)) {
+ // force re-stemming, new case normalization, etc
+ wordItem.setStemmed(false);
+ wordItem.setLowercased(false);
+ wordItem.setNormalizable(true);
+ }
+ }
+
+ @NonNull
+ private <T extends TaggableItem> T leafStyleSettings(OperatorNode<?> ast,
+ @NonNull
+ T out) {
+ {
+ Map<?, ?> connectivity = getAnnotation(ast, CONNECTIVITY,
+ Map.class, null, "connectivity settings");
+ if (connectivity != null) {
+ connectedItems.add(new ConnectedItem(out, getMapValue(
+ CONNECTIVITY, connectivity, CONNECTION_ID,
+ Integer.class), getMapValue(CONNECTIVITY, connectivity,
+ CONNECTION_WEIGHT, Number.class).doubleValue()));
+ }
+ Number significance = getAnnotation(ast, SIGNIFICANCE,
+ Number.class, null, "term significance");
+ if (significance != null) {
+ out.setSignificance(significance.doubleValue());
+ }
+ Integer uniqueId = getAnnotation(ast, UNIQUE_ID, Integer.class,
+ null, "term ID", false);
+ if (uniqueId != null) {
+ out.setUniqueID(uniqueId);
+ identifiedItems.put(uniqueId, out);
+ }
+ }
+ {
+ Item leaf = (Item) out;
+ Map<?, ?> itemAnnotations = getAnnotation(ast, ANNOTATIONS,
+ Map.class, Collections.emptyMap(), "item annotation map");
+ for (Map.Entry<?, ?> entry : itemAnnotations.entrySet()) {
+ Preconditions.checkArgument(entry.getKey() instanceof String,
+ "Expected String annotation key, got %s.", entry
+ .getKey().getClass());
+ Preconditions.checkArgument(entry.getValue() instanceof String,
+ "Expected String annotation value, got %s.", entry
+ .getValue().getClass());
+ leaf.addAnnotation((String) entry.getKey(), entry.getValue());
+ }
+ Boolean filter = getAnnotation(ast, FILTER, Boolean.class, null,
+ FILTER_DESCRIPTION);
+ if (filter != null) {
+ leaf.setFilter(filter);
+ }
+ Boolean isRanked = getAnnotation(ast, RANKED, Boolean.class, null,
+ RANKED_DESCRIPTION);
+ if (isRanked != null) {
+ leaf.setRanked(isRanked);
+ }
+ String label = getAnnotation(ast, LABEL, String.class, null,
+ "item label");
+ if (label != null) {
+ leaf.setLabel(label);
+ }
+ Integer weight = getAnnotation(ast, WEIGHT, Integer.class, null,
+ "term weight for ranking");
+ if (weight != null) {
+ leaf.setWeight(weight);
+ }
+ }
+ if (out instanceof IntItem) {
+ IntItem number = (IntItem) out;
+ Integer hitLimit = getCappedRangeSearchParameter(ast);
+ if (hitLimit != null) {
+ number.setHitLimit(hitLimit.intValue());
+ }
+ }
+
+ return out;
+ }
+
+ private Integer getCappedRangeSearchParameter(OperatorNode<?> ast) {
+ Integer hitLimit = getAnnotation(ast, HIT_LIMIT, Integer.class, null, "hit limit");
+
+ if (hitLimit != null) {
+ Boolean ascending = getAnnotation(ast, ASCENDING_HITS_ORDER, Boolean.class, null,
+ "ascending population ordering for capped range search");
+ Boolean descending = getAnnotation(ast, DESCENDING_HITS_ORDER, Boolean.class, null,
+ "descending population ordering for capped range search");
+ Preconditions.checkArgument(ascending == null || descending == null,
+ "Settings for both ascending and descending ordering set, only one of these expected.");
+ if (Boolean.TRUE.equals(descending) || Boolean.FALSE.equals(ascending)) {
+ hitLimit = Integer.valueOf(hitLimit.intValue() * -1);
+ }
+ }
+ return hitLimit;
+ }
+
+ @Beta
+ public boolean isQueryParser() {
+ return queryParser;
+ }
+
+ @Beta
+ public void setQueryParser(boolean queryParser) {
+ this.queryParser = queryParser;
+ }
+
+ @Beta
+ public void setUserQuery(@NonNull Query userQuery) {
+ this.userQuery = userQuery;
+ }
+
+ @Beta
+ public Set<String> getYqlSummaryFields() {
+ return yqlSummaryFields;
+ }
+
+ @Beta
+ public List<VespaGroupingStep> getGroupingSteps() {
+ return groupingSteps;
+ }
+
+ /**
+ * Give the offset expected from the latest parsed query if anything is
+ * explicitly specified.
+ *
+ * @return an Integer instance or null
+ */
+ public Integer getOffset() {
+ return offset;
+ }
+
+ /**
+ * Give the number of hits expected from the latest parsed query if anything
+ * is explicitly specified.
+ *
+ * @return an Integer instance or null
+ */
+ public Integer getHits() {
+ return hits;
+ }
+
+ /**
+ * The timeout specified in the YQL+ query last parsed.
+ *
+ * @return an Integer instance or null
+ */
+ public Integer getTimeout() {
+ return timeout;
+ }
+
+ /**
+ * The sorting specified in the YQL+ query last parsed.
+ *
+ * @return a Sorting instance or null
+ */
+ public Sorting getSorting() {
+ return sorting;
+ }
+
+ Set<String> getDocTypes() {
+ return docTypes;
+ }
+
+ Set<String> getYqlSources() {
+ return yqlSources;
+ }
+
+ private static void assertHasOperator(OperatorNode<?> ast,
+ Class<? extends Operator> expectedOperatorClass) {
+ Preconditions.checkArgument(
+ expectedOperatorClass.isInstance(ast.getOperator()),
+ "Expected operator class %s, got %s.",
+ expectedOperatorClass.getName(), ast.getOperator().getClass()
+ .getName());
+ }
+
+ private static void assertHasOperator(OperatorNode<?> ast,
+ Operator expectedOperator) {
+ Preconditions.checkArgument(ast.getOperator() == expectedOperator,
+ "Expected operator %s, got %s.", expectedOperator,
+ ast.getOperator());
+ }
+
+ private static void assertHasFunctionName(OperatorNode<?> ast,
+ String expectedFunctionName) {
+ List<String> names = ast.getArgument(0);
+ Preconditions.checkArgument(expectedFunctionName.equals(names.get(0)),
+ "Expected function '%s', got '%s'.", expectedFunctionName,
+ names.get(0));
+ }
+
+ private static void addItems(OperatorNode<ExpressionOperator> ast,
+ WeightedSetItem out) {
+ switch (ast.getOperator()) {
+ case MAP:
+ addStringItems(ast, out);
+ break;
+ case ARRAY:
+ addLongItems(ast, out);
+ break;
+ default:
+ throw newUnexpectedArgumentException(ast.getOperator(),
+ ExpressionOperator.ARRAY, ExpressionOperator.MAP);
+ }
+ }
+
+ private static void addStringItems(OperatorNode<ExpressionOperator> ast,
+ WeightedSetItem out) {
+ List<String> keys = ast.getArgument(0);
+ List<OperatorNode<ExpressionOperator>> values = ast.getArgument(1);
+ for (int i = 0; i < keys.size(); ++i) {
+ OperatorNode<ExpressionOperator> tokenWeight = values.get(i);
+ assertHasOperator(tokenWeight, ExpressionOperator.LITERAL);
+ out.addToken(keys.get(i), tokenWeight.getArgument(0, Integer.class));
+ }
+ }
+
+ private static void addLongItems(OperatorNode<ExpressionOperator> ast,
+ WeightedSetItem out) {
+ List<OperatorNode<ExpressionOperator>> values = ast.getArgument(0);
+ for (OperatorNode<ExpressionOperator> value : values) {
+ assertHasOperator(value, ExpressionOperator.ARRAY);
+ List<OperatorNode<ExpressionOperator>> args = value.getArgument(0);
+ Preconditions.checkArgument(args.size() == 2,
+ "Expected item and weight, got %s.", args);
+
+ OperatorNode<ExpressionOperator> tokenValueNode = args.get(0);
+ assertHasOperator(tokenValueNode, ExpressionOperator.LITERAL);
+ Number tokenValue = tokenValueNode.getArgument(0, Number.class);
+ Preconditions.checkArgument(tokenValue instanceof Integer
+ || tokenValue instanceof Long,
+ "Expected Integer or Long, got %s.", tokenValue.getClass()
+ .getName());
+
+ OperatorNode<ExpressionOperator> tokenWeightNode = args.get(1);
+ assertHasOperator(tokenWeightNode, ExpressionOperator.LITERAL);
+ Integer tokenWeight = tokenWeightNode.getArgument(0, Integer.class);
+
+ out.addToken(tokenValue.longValue(), tokenWeight);
+ }
+ }
+
+ private void wordStyleSettings(OperatorNode<ExpressionOperator> ast,
+ WordItem out) {
+ Substring origin = getOrigin(ast);
+ if (origin != null) {
+ out.setOrigin(origin);
+ }
+ Boolean usePositionData = getAnnotation(ast, USE_POSITION_DATA,
+ Boolean.class, null,
+ USE_POSITION_DATA_DESCRIPTION);
+ if (usePositionData != null) {
+ out.setPositionData(usePositionData);
+ }
+ Boolean stem = getAnnotation(ast, STEM, Boolean.class, null,
+ STEM_DESCRIPTION);
+ if (stem != null) {
+ out.setStemmed(!stem);
+ }
+ Boolean normalizeCase = getAnnotation(ast, NORMALIZE_CASE,
+ Boolean.class, null,
+ NORMALIZE_CASE_DESCRIPTION);
+ if (normalizeCase != null) {
+ out.setLowercased(!normalizeCase);
+ }
+ Boolean accentDrop = getAnnotation(ast, ACCENT_DROP, Boolean.class,
+ null,
+ ACCENT_DROP_DESCRIPTION);
+ if (accentDrop != null) {
+ out.setNormalizable(accentDrop);
+ }
+ Boolean andSegmenting = getAnnotation(ast, AND_SEGMENTING,
+ Boolean.class, null,
+ "setting for whether to force using AND for segments on and off");
+ if (andSegmenting != null) {
+ if (andSegmenting) {
+ out.setSegmentingRule(SegmentingRule.BOOLEAN_AND);
+ } else {
+ out.setSegmentingRule(SegmentingRule.PHRASE);
+ }
+ }
+ }
+
+ @NonNull
+ private String getIndex(OperatorNode<ExpressionOperator> operatorNode) {
+ String index = fetchFieldRead(operatorNode);
+ Preconditions.checkArgument(indexFactsSession.isIndex(index), "Field '%s' does not exist.", index);
+ return indexFactsSession.getCanonicName(index);
+ }
+
+ private Substring getOrigin(OperatorNode<ExpressionOperator> ast) {
+ Map<?, ?> origin = getAnnotation(ast, ORIGIN, Map.class, null,
+ ORIGIN_DESCRIPTION);
+ if (origin == null) {
+ return null;
+ }
+ String original = getMapValue(ORIGIN, origin, ORIGIN_ORIGINAL,
+ String.class);
+ int offset = getMapValue(ORIGIN, origin, ORIGIN_OFFSET, Integer.class);
+ int length = getMapValue(ORIGIN, origin, ORIGIN_LENGTH, Integer.class);
+ return new Substring(offset, length + offset, original);
+ }
+
+ private static <T> T getMapValue(String mapName, Map<?, ?> map, String key,
+ Class<T> expectedValueClass) {
+ Object value = map.get(key);
+ Preconditions.checkArgument(value != null,
+ "Map annotation '%s' must contain an entry with key '%s'.",
+ mapName, key);
+ assert value != null;
+ Preconditions.checkArgument(expectedValueClass.isInstance(value),
+ "Expected %s for entry '%s' in map annotation '%s', got %s.",
+ expectedValueClass.getName(), key, mapName, value.getClass()
+ .getName());
+ return expectedValueClass.cast(value);
+ }
+
+ private <T> T getAnnotation(OperatorNode<?> ast, String key,
+ Class<T> expectedClass, T defaultValue, String description) {
+ return getAnnotation(ast, key, expectedClass, defaultValue,
+ description, true);
+ }
+
+ private <T> T getAnnotation(OperatorNode<?> ast, String key,
+ Class<T> expectedClass, T defaultValue, String description, boolean considerParents) {
+ Object value = ast.getAnnotation(key);
+ for (Iterator<OperatorNode<?>> i = annotationStack.iterator(); value == null
+ && considerParents && i.hasNext();) {
+ value = i.next().getAnnotation(key);
+ }
+ if (value == null) {
+ return defaultValue;
+ }
+ Preconditions.checkArgument(expectedClass.isInstance(value),
+ "Expected %s for annotation '%s' (%s), got %s.", expectedClass
+ .getName(), key, description, value.getClass()
+ .getName());
+ return expectedClass.cast(value);
+ }
+
+ private static IllegalArgumentException newUnexpectedArgumentException(
+ Object actual, Object... expected) {
+ StringBuilder out = new StringBuilder("Expected ");
+ for (int i = 0, len = expected.length; i < len; ++i) {
+ out.append(expected[i]);
+ if (i < len - 2) {
+ out.append(", ");
+ } else if (i < len - 1) {
+ out.append(" or ");
+ }
+ }
+ out.append(", got ").append(actual).append(".");
+ return new IllegalArgumentException(out.toString());
+ }
+
+ String getSegmenterBackend() {
+ return segmenterBackend;
+ }
+
+ Version getSegmenterVersion() {
+ return segmenterVersion;
+ }
+
+ private static final class ConnectedItem {
+
+ final double weight;
+ final int toId;
+ final TaggableItem fromItem;
+
+ ConnectedItem(TaggableItem fromItem, int toId, double weight) {
+ this.weight = weight;
+ this.toId = toId;
+ this.fromItem = fromItem;
+ }
+ }
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/YqlQuery.java b/container-search/src/main/java/com/yahoo/search/yql/YqlQuery.java
new file mode 100644
index 00000000000..27c27b88d24
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/YqlQuery.java
@@ -0,0 +1,22 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.search.yql;
+
+/**
+ * A Yql query. These usually contains variables, which allows the yql query to be parsed once at configuration
+ * time and turned into fully specified queries at request time without reparsing.
+ *
+ * @author bratseth
+ */
+// TODO: This is just a skeleton
+public class YqlQuery {
+
+ private YqlQuery(String yqlQuery) {
+ // TODO
+ }
+
+ /** Creates a YQl query form a string */
+ public static YqlQuery from(String yqlQueryString) {
+ return new YqlQuery(yqlQueryString);
+ }
+
+}
diff --git a/container-search/src/main/java/com/yahoo/search/yql/package-info.java b/container-search/src/main/java/com/yahoo/search/yql/package-info.java
new file mode 100644
index 00000000000..79cf983e471
--- /dev/null
+++ b/container-search/src/main/java/com/yahoo/search/yql/package-info.java
@@ -0,0 +1,11 @@
+// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * YQL+ integration.
+ *
+ * <p>Not a public API.</p>
+ */
+@ExportPackage
+package com.yahoo.search.yql;
+
+import com.yahoo.osgi.annotation.ExportPackage;
+