diff --git a/docs/Searching/FluentQueryPlacement.md b/docs/Searching/FluentQueryPlacement.md new file mode 100644 index 0000000..987b32b --- /dev/null +++ b/docs/Searching/FluentQueryPlacement.md @@ -0,0 +1,129 @@ +# Fluent Query Placement Control - BooleanQueryBuilder + +This enhancement adds fluent convenience methods for precise control of query item placement within boolean query structures. + +## Problem Statement + +Previously, users had to manually manipulate query structures for precise placement: + +```coldfusion +var q = search.getQuery(); +param q.bool = {}; +param q.bool.filter = {}; +param q.bool.filter.bool.must = []; +arrayAppend( q.bool.filter.bool.must, { + "wildcard" : { + "fieldName" : { + "value" : "queryValue" + } + } +} ); +``` + +## Solution + +The new fluent API allows intuitive query building: + +```coldfusion +// Same result as manual approach above: +search.bool().filter().bool().must().wildcard( "fieldName", "queryValue" ); + +// Other examples: +search.must().term( "status", "active" ); +search.should().match( "title", "elasticsearch" ); +search.filter().range( "price", gte = 10, lte = 100 ); +search.mustNot().exists( "deletedAt" ); +``` + +## Available Methods + +### Entry Points from SearchBuilder +- `bool()` - Creates boolean query context at `query.bool` +- `must()` - Direct access to `query.bool.must[]` +- `should()` - Direct access to `query.bool.should[]` +- `mustNot()` - Direct access to `query.bool.must_not[]` +- `filter()` - Direct access to `query.bool.filter` + +### Chaining Methods in BooleanQueryBuilder +- `bool()` - Add nested boolean context +- `must()` - Add must array context +- `should()` - Add should array context +- `mustNot()` - Add must_not array context +- `filter()` - Add filter context + +### Query Methods (return SearchBuilder for continued chaining) +- `term( name, value, [boost] )` - Exact term match +- `terms( name, values, [boost] )` - Multiple term matches +- `match( name, value, [boost] )` - Full-text match +- `wildcard( name, pattern, [boost] )` - Wildcard pattern match +- `range( name, [gte], [gt], [lte], [lt], [boost] )` - Range query +- `exists( name )` - Field existence check + +## Complex Query Examples + +### Nested Boolean Logic +```coldfusion +// Creates: query.bool.filter.bool.should[] +search.bool().filter().bool().should() + .match( "title", "elasticsearch" ) + .match( "description", "search engine" ); +``` + +### Mixed Query Types +```coldfusion +search + .must().term( "status", "published" ) + .should().match( "title", "important" ) + .filter().range( "publishDate", gte = "2023-01-01" ) + .mustNot().exists( "deletedAt" ); +``` + +### Deeply Nested Structures +```coldfusion +// Equivalent to: query.bool.must[].bool.filter.bool.should[] +search.bool().must().bool().filter().bool().should() + .wildcard( "tags", "*elasticsearch*" ); +``` + +## Backward Compatibility + +All existing SearchBuilder methods continue to work unchanged: + +```coldfusion +// Old API still works +search.mustMatch( "title", "test" ); +search.filterTerm( "status", "active" ); + +// Can be mixed with new API +search.mustMatch( "title", "test" ) + .must().term( "category", "tech" ); +``` + +## Generated Query Structures + +The fluent API generates standard Elasticsearch query DSL: + +```json +{ + "query": { + "bool": { + "must": [ + { "term": { "status": "active" } } + ], + "should": [ + { "match": { "title": "elasticsearch" } } + ], + "filter": { + "range": { + "price": { "gte": 10, "lte": 100 } + } + }, + "must_not": [ + { "exists": { "field": "deletedAt" } } + ] + } + } +} +``` + +This enhancement significantly improves the developer experience for building complex Elasticsearch queries while maintaining full backward compatibility. \ No newline at end of file diff --git a/docs/Searching/Search.md b/docs/Searching/Search.md index 84bbe24..06cfeae 100644 --- a/docs/Searching/Search.md +++ b/docs/Searching/Search.md @@ -473,4 +473,39 @@ var result = getInstance( "SearchBuilder@cbelasticsearch" ) * `sort(any sort, [any sortConfig])` - Applies a custom sort to the search query. * `term(string name, any value, [numeric boost])` - Adds an exact value restriction ( elasticsearch: term ) to the query. * `aggregation(string name, struct options)` - Adds an aggregation directive to the search parameters. -* `collapseToField( string field, struct options, boolean includeOccurrences = false )` - Collapses the results to the single field and returns only the most relevant/ordered document matched on that field. \ No newline at end of file +* `collapseToField( string field, struct options, boolean includeOccurrences = false )` - Collapses the results to the single field and returns only the most relevant/ordered document matched on that field. + +### Fluent Query Placement API + +The following methods provide fluent control over query item placement within boolean query structures: + +* `bool()` - Creates a BooleanQueryBuilder for fluent boolean query construction starting at `query.bool`. +* `must()` - Creates a BooleanQueryBuilder targeting `query.bool.must[]` for required matches. +* `should()` - Creates a BooleanQueryBuilder targeting `query.bool.should[]` for optional matches. +* `mustNot()` - Creates a BooleanQueryBuilder targeting `query.bool.must_not[]` for exclusions. +* `filter()` - Creates a BooleanQueryBuilder targeting `query.bool.filter` for filtered queries. + +**BooleanQueryBuilder Methods:** +* `bool()`, `must()`, `should()`, `mustNot()`, `filter()` - Continue building nested boolean contexts. +* `term(name, value, [boost])` - Add exact term match at current path. +* `terms(name, values, [boost])` - Add multiple terms match at current path. +* `match(name, value, [boost])` - Add full-text match at current path. +* `wildcard(name, pattern, [boost])` - Add wildcard pattern match at current path. +* `range(name, [gte], [gt], [lte], [lt], [boost])` - Add range query at current path. +* `exists(name)` - Add field existence check at current path. + +**Examples:** +```js +// Simple placement +search.must().term( "status", "active" ); + +// Nested placement (replaces manual query manipulation) +search.bool().filter().bool().must().wildcard( "title", "*test*" ); + +// Complex chaining +search.must().term( "status", "active" ) + .should().match( "title", "important" ) + .filter().range( "price", gte = 10, lte = 100 ); +``` + +See [Fluent Query Placement](FluentQueryPlacement.md) for detailed documentation and examples. \ No newline at end of file diff --git a/models/BooleanQueryBuilder.cfc b/models/BooleanQueryBuilder.cfc new file mode 100644 index 0000000..42beb8e --- /dev/null +++ b/models/BooleanQueryBuilder.cfc @@ -0,0 +1,276 @@ +/** + * Boolean Query Builder for fluent query placement control + * + * Allows for precise placement of query operators within boolean query structures + * like query.bool.must[], query.bool.filter.bool.should[], etc. + * + * @package cbElasticsearch.models + * @author cbElasticSearch Module + * @license Apache v2.0 + */ +component accessors="true" { + + /** + * Reference to the parent SearchBuilder instance + */ + property name="searchBuilder"; + + /** + * The current query path being built (e.g., "query.bool.must") + */ + property name="queryPath"; + + /** + * Constructor + * + * @searchBuilder The parent SearchBuilder instance + * @queryPath The dot-notation path where queries should be placed + */ + function init( required any searchBuilder, string queryPath = "query" ){ + variables.searchBuilder = arguments.searchBuilder; + variables.queryPath = arguments.queryPath; + return this; + } + + /** + * Creates a new boolean query context at query.bool + * + * @return BooleanQueryBuilder A new builder instance for the bool context + */ + BooleanQueryBuilder function bool(){ + var newPath = variables.queryPath == "query" ? "query.bool" : variables.queryPath & ".bool"; + return new BooleanQueryBuilder( variables.searchBuilder, newPath ); + } + + /** + * Creates a new must query context + * + * @return BooleanQueryBuilder A new builder instance for the must context + */ + BooleanQueryBuilder function must(){ + var newPath = variables.queryPath & ".must"; + return new BooleanQueryBuilder( variables.searchBuilder, newPath ); + } + + /** + * Creates a new should query context + * + * @return BooleanQueryBuilder A new builder instance for the should context + */ + BooleanQueryBuilder function should(){ + var newPath = variables.queryPath & ".should"; + return new BooleanQueryBuilder( variables.searchBuilder, newPath ); + } + + /** + * Creates a new must_not query context + * + * @return BooleanQueryBuilder A new builder instance for the must_not context + */ + BooleanQueryBuilder function mustNot(){ + var newPath = variables.queryPath & ".must_not"; + return new BooleanQueryBuilder( variables.searchBuilder, newPath ); + } + + /** + * Creates a new filter query context + * + * @return BooleanQueryBuilder A new builder instance for the filter context + */ + BooleanQueryBuilder function filter(){ + var newPath = variables.queryPath & ".filter"; + return new BooleanQueryBuilder( variables.searchBuilder, newPath ); + } + + /** + * Adds a term query at the current query path + * + * @name The field name + * @value The field value + * @boost Optional boost value + * + * @return SearchBuilder The parent SearchBuilder for continued chaining + */ + SearchBuilder function term( + required string name, + required any value, + numeric boost + ){ + var termQuery = { "term" : { "#arguments.name#" : arguments.value } }; + + if ( !isNull( arguments.boost ) ) { + termQuery.term[ arguments.name ] = { + "value" : arguments.value, + "boost" : javacast( "float", arguments.boost ) + }; + } + + appendToQueryPath( termQuery ); + return variables.searchBuilder; + } + + /** + * Adds a terms query at the current query path + * + * @name The field name + * @value The field values (array or list) + * @boost Optional boost value + * + * @return SearchBuilder The parent SearchBuilder for continued chaining + */ + SearchBuilder function terms( + required string name, + required any value, + numeric boost + ){ + if ( !isArray( arguments.value ) ) { + arguments.value = listToArray( arguments.value ); + } + + var termsQuery = { "terms" : { "#arguments.name#" : arguments.value } }; + + if ( !isNull( arguments.boost ) ) { + termsQuery.terms[ arguments.name ] = { + "value" : arguments.value, + "boost" : javacast( "float", arguments.boost ) + }; + } + + appendToQueryPath( termsQuery ); + return variables.searchBuilder; + } + + /** + * Adds a match query at the current query path + * + * @name The field name + * @value The field value + * @boost Optional boost value + * + * @return SearchBuilder The parent SearchBuilder for continued chaining + */ + SearchBuilder function match( + required string name, + required any value, + numeric boost + ){ + var matchQuery = { "match" : { "#arguments.name#" : arguments.value } }; + + if ( !isNull( arguments.boost ) ) { + matchQuery.match[ arguments.name ] = { + "query" : arguments.value, + "boost" : javacast( "float", arguments.boost ) + }; + } + + appendToQueryPath( matchQuery ); + return variables.searchBuilder; + } + + /** + * Adds a wildcard query at the current query path + * + * @name The field name + * @value The wildcard pattern + * @boost Optional boost value + * + * @return SearchBuilder The parent SearchBuilder for continued chaining + */ + SearchBuilder function wildcard( + required string name, + required string value, + numeric boost + ){ + var wildcardQuery = { "wildcard" : { "#arguments.name#" : { "value" : arguments.value } } }; + + if ( !isNull( arguments.boost ) ) { + wildcardQuery.wildcard[ arguments.name ][ "boost" ] = javacast( "float", arguments.boost ); + } + + appendToQueryPath( wildcardQuery ); + return variables.searchBuilder; + } + + /** + * Adds a range query at the current query path + * + * @name The field name + * @gte Greater than or equal to value + * @gt Greater than value + * @lte Less than or equal to value + * @lt Less than value + * @boost Optional boost value + * + * @return SearchBuilder The parent SearchBuilder for continued chaining + */ + SearchBuilder function range( + required string name, + any gte, + any gt, + any lte, + any lt, + numeric boost + ){ + var rangeParams = {}; + + if ( !isNull( arguments.gte ) ) rangeParams[ "gte" ] = arguments.gte; + if ( !isNull( arguments.gt ) ) rangeParams[ "gt" ] = arguments.gt; + if ( !isNull( arguments.lte ) ) rangeParams[ "lte" ] = arguments.lte; + if ( !isNull( arguments.lt ) ) rangeParams[ "lt" ] = arguments.lt; + if ( !isNull( arguments.boost ) ) rangeParams[ "boost" ] = javacast( "float", arguments.boost ); + + var rangeQuery = { "range" : { "#arguments.name#" : rangeParams } }; + + appendToQueryPath( rangeQuery ); + return variables.searchBuilder; + } + + /** + * Adds an exists query at the current query path + * + * @name The field name to check for existence + * + * @return SearchBuilder The parent SearchBuilder for continued chaining + */ + SearchBuilder function exists( required string name ){ + var existsQuery = { "exists" : { "field" : arguments.name } }; + appendToQueryPath( existsQuery ); + return variables.searchBuilder; + } + + /** + * Private helper method to append a query to the current query path + * + * @query The query object to append + */ + private void function appendToQueryPath( required struct query ){ + var pathParts = listToArray( variables.queryPath, "." ); + var queryRef = variables.searchBuilder.getQuery(); + var current = queryRef; + + // Navigate to the target location, creating structures as needed + for ( var i = 2; i <= arrayLen( pathParts ); i++ ) { + var part = pathParts[ i ]; + + if ( !structKeyExists( current, part ) ) { + // Determine if this should be an array or struct based on the context + if ( arrayFind( [ "must", "should", "must_not" ], part ) ) { + current[ part ] = []; + } else { + current[ part ] = {}; + } + } + + current = current[ part ]; + } + + // Append to array or set in struct + var lastPart = pathParts[ arrayLen( pathParts ) ]; + if ( arrayFind( [ "must", "should", "must_not" ], lastPart ) ) { + arrayAppend( current, arguments.query ); + } else { + structAppend( current, arguments.query, true ); + } + } + +} diff --git a/models/SearchBuilder.cfc b/models/SearchBuilder.cfc index 7dbdf71..7473c61 100644 --- a/models/SearchBuilder.cfc +++ b/models/SearchBuilder.cfc @@ -1389,4 +1389,49 @@ component accessors="true" { return this; } + /** + * Creates a fluent boolean query builder for precise query placement control + * + * @return BooleanQueryBuilder A new builder instance for fluent boolean query construction + */ + BooleanQueryBuilder function bool(){ + return new BooleanQueryBuilder( this, "query" ); + } + + /** + * Creates a fluent must query builder for adding queries to query.bool.must[] + * + * @return BooleanQueryBuilder A new builder instance for the must context + */ + BooleanQueryBuilder function must(){ + return new BooleanQueryBuilder( this, "query.bool.must" ); + } + + /** + * Creates a fluent should query builder for adding queries to query.bool.should[] + * + * @return BooleanQueryBuilder A new builder instance for the should context + */ + BooleanQueryBuilder function should(){ + return new BooleanQueryBuilder( this, "query.bool.should" ); + } + + /** + * Creates a fluent must_not query builder for adding queries to query.bool.must_not[] + * + * @return BooleanQueryBuilder A new builder instance for the must_not context + */ + BooleanQueryBuilder function mustNot(){ + return new BooleanQueryBuilder( this, "query.bool.must_not" ); + } + + /** + * Creates a fluent filter query builder for adding queries to query.bool.filter + * + * @return BooleanQueryBuilder A new builder instance for the filter context + */ + BooleanQueryBuilder function filter(){ + return new BooleanQueryBuilder( this, "query.bool.filter" ); + } + } diff --git a/test-harness/manual_validation.cfm b/test-harness/manual_validation.cfm new file mode 100644 index 0000000..5afd9e9 --- /dev/null +++ b/test-harness/manual_validation.cfm @@ -0,0 +1,166 @@ + +/** + * Manual validation script for BooleanQueryBuilder + * This tests the new fluent API functionality + */ + +try { + // Get the SearchBuilder instance (this should work if the module is properly configured) + searchBuilder = new cbelasticsearch.models.SearchBuilder(); + + writeOutput("

BooleanQueryBuilder Manual Validation

"); + + // Test 1: Basic fluent must() method + writeOutput("

Test 1: Basic must().term() fluent API

"); + testBuilder1 = new cbelasticsearch.models.SearchBuilder(); + testBuilder1.new( "test_index", "test_type" ); + + // This should work: must().term() + testBuilder1.must().term( "status", "active" ); + query1 = testBuilder1.getQuery(); + + writeOutput("

Query Structure:

"); + writeOutput("
" & serializeJSON( query1, false, true ) & "
"); + + // Verify structure + hasCorrectStructure1 = structKeyExists( query1, "bool" ) + && structKeyExists( query1.bool, "must" ) + && isArray( query1.bool.must ) + && arrayLen( query1.bool.must ) == 1 + && structKeyExists( query1.bool.must[1], "term" ) + && structKeyExists( query1.bool.must[1].term, "status" ) + && query1.bool.must[1].term.status == "active"; + + writeOutput("

Validation: " & (hasCorrectStructure1 ? "PASS" : "FAIL") & "

"); + + // Test 2: Nested fluent API - bool().filter().bool().must() + writeOutput("

Test 2: Nested bool().filter().bool().must().wildcard() API

"); + testBuilder2 = new cbelasticsearch.models.SearchBuilder(); + testBuilder2.new( "test_index", "test_type" ); + + // This tests the original issue case + testBuilder2.bool().filter().bool().must().wildcard( "title", "*test*" ); + query2 = testBuilder2.getQuery(); + + writeOutput("

Query Structure:

"); + writeOutput("
" & serializeJSON( query2, false, true ) & "
"); + + // Verify nested structure + hasCorrectStructure2 = structKeyExists( query2, "bool" ) + && structKeyExists( query2.bool, "filter" ) + && structKeyExists( query2.bool.filter, "bool" ) + && structKeyExists( query2.bool.filter.bool, "must" ) + && isArray( query2.bool.filter.bool.must ) + && arrayLen( query2.bool.filter.bool.must ) == 1 + && structKeyExists( query2.bool.filter.bool.must[1], "wildcard" ) + && structKeyExists( query2.bool.filter.bool.must[1].wildcard, "title" ); + + writeOutput("

Validation: " & (hasCorrectStructure2 ? "PASS" : "FAIL") & "

"); + + // Test 3: Multiple chained operations + writeOutput("

Test 3: Multiple chained operations

"); + testBuilder3 = new cbelasticsearch.models.SearchBuilder(); + testBuilder3.new( "test_index", "test_type" ); + + // Chain multiple operations + testBuilder3 + .must().term( "status", "active" ) + .should().match( "title", "elasticsearch" ) + .filter().range( "price", gte = 10, lte = 100 ); + + query3 = testBuilder3.getQuery(); + + writeOutput("

Query Structure:

"); + writeOutput("
" & serializeJSON( query3, false, true ) & "
"); + + // Verify multiple structures + hasCorrectStructure3 = structKeyExists( query3, "bool" ) + && structKeyExists( query3.bool, "must" ) + && structKeyExists( query3.bool, "should" ) + && structKeyExists( query3.bool, "filter" ) + && isArray( query3.bool.must ) + && isArray( query3.bool.should ) + && structKeyExists( query3.bool.filter, "range" ); + + writeOutput("

Validation: " & (hasCorrectStructure3 ? "PASS" : "FAIL") & "

"); + + // Test 4: Comparison with original manual approach + writeOutput("

Test 4: Comparison with Manual Approach

"); + + // Manual approach (what we're replacing) + manualBuilder = new cbelasticsearch.models.SearchBuilder(); + manualBuilder.new( "test_index", "test_type" ); + var q = manualBuilder.getQuery(); + param q.bool = {}; + param q.bool.filter = {}; + param q.bool.filter.bool.must = []; + arrayAppend( q.bool.filter.bool.must, { + "wildcard" : { + "title" : { + "value" : "*test*" + } + } + } ); + + writeOutput("

Manual Query Structure:

"); + writeOutput("
" & serializeJSON( manualBuilder.getQuery(), false, true ) & "
"); + + writeOutput("

Fluent Query Structure (from Test 2):

"); + writeOutput("
" & serializeJSON( query2, false, true ) & "
"); + + // Compare structures + manualJSON = serializeJSON( manualBuilder.getQuery(), false, false ); + fluentJSON = serializeJSON( query2, false, false ); + structuresMatch = manualJSON == fluentJSON; + + writeOutput("

Structures Match: " & (structuresMatch ? "PASS" : "FAIL") & "

"); + + if (!structuresMatch) { + writeOutput("

Manual JSON:

" & manualJSON & "
"); + writeOutput("

Fluent JSON:

" & fluentJSON & "
"); + } + + // Test 5: Backward compatibility + writeOutput("

Test 5: Backward Compatibility

"); + testBuilder4 = new cbelasticsearch.models.SearchBuilder(); + testBuilder4.new( "test_index", "test_type" ); + + // Mix old and new APIs + testBuilder4.mustMatch( "title", "elasticsearch" ); // Old API + testBuilder4.must().term( "status", "active" ); // New API + + query4 = testBuilder4.getQuery(); + writeOutput("

Mixed API Query Structure:

"); + writeOutput("
" & serializeJSON( query4, false, true ) & "
"); + + // Should have 2 items in must array + backwardCompatible = structKeyExists( query4, "bool" ) + && structKeyExists( query4.bool, "must" ) + && isArray( query4.bool.must ) + && arrayLen( query4.bool.must ) == 2; + + writeOutput("

Backward Compatibility: " & (backwardCompatible ? "PASS" : "FAIL") & "

"); + + // Summary + writeOutput("

Summary

"); + allTestsPass = hasCorrectStructure1 && hasCorrectStructure2 && hasCorrectStructure3 && structuresMatch && backwardCompatible; + writeOutput("

Overall Result: " & (allTestsPass ? "ALL TESTS PASS" : "SOME TESTS FAILED") & "

"); + + if (allTestsPass) { + writeOutput("

✓ BooleanQueryBuilder implementation is working correctly!

"); + writeOutput("

The fluent API successfully replaces manual query structure manipulation.

"); + } + +} catch (any e) { + writeOutput("

Error in Manual Validation

"); + writeOutput("

Error Details:

"); + writeOutput("
" & serializeJSON( e, false, true ) & "
"); + + writeOutput("

Troubleshooting:

"); + writeOutput(""); +} +
\ No newline at end of file diff --git a/test-harness/tests/specs/unit/BooleanQueryBuilderTest.cfc b/test-harness/tests/specs/unit/BooleanQueryBuilderTest.cfc new file mode 100644 index 0000000..77050ba --- /dev/null +++ b/test-harness/tests/specs/unit/BooleanQueryBuilderTest.cfc @@ -0,0 +1,233 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + this.loadColdbox = true; + + function beforeAll(){ + super.beforeAll(); + + variables.model = getWirebox().getInstance( "BooleanQueryBuilder@cbElasticSearch" ); + variables.searchBuilder = getWirebox().getInstance( "SearchBuilder@cbElasticSearch" ); + + variables.testIndexName = lCase( "booleanQueryBuilderTests" ); + variables.searchBuilder.getClient().deleteIndex( variables.testIndexName ); + + // create our new index + getWirebox() + .getInstance( "IndexBuilder@cbelasticsearch" ) + .new( + name = variables.testIndexName, + properties = { + "mappings" : { + "testdocs" : { + "_all" : { "enabled" : false }, + "properties" : { + "title" : { "type" : "text" }, + "createdTime" : { "type" : "date", "format" : "date_time_no_millis" }, + "price" : { "type" : "float" }, + "status" : { "type" : "keyword" } + } + } + } + } + ) + .save(); + } + + function afterAll(){ + variables.searchBuilder.getClient().deleteIndex( variables.testIndexName ); + super.afterAll(); + } + + function run(){ + describe( "Performs cbElasticsearch BooleanQueryBuilder fluent API tests", function(){ + + it( "Tests fluent bool().must().term() placement", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.bool().must().term( "status", "active" ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "must" ); + expect( query.bool.must ).toBeArray().toHaveLength( 1 ); + expect( query.bool.must[ 1 ] ).toHaveKey( "term" ); + expect( query.bool.must[ 1 ].term ).toHaveKey( "status" ); + expect( query.bool.must[ 1 ].term.status ).toBe( "active" ); + } ); + + it( "Tests fluent bool().should().match() placement", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.bool().should().match( "title", "elasticsearch" ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "should" ); + expect( query.bool.should ).toBeArray().toHaveLength( 1 ); + expect( query.bool.should[ 1 ] ).toHaveKey( "match" ); + expect( query.bool.should[ 1 ].match ).toHaveKey( "title" ); + expect( query.bool.should[ 1 ].match.title ).toBe( "elasticsearch" ); + } ); + + it( "Tests fluent bool().filter().bool().must().wildcard() nested placement", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.bool().filter().bool().must().wildcard( "title", "*test*" ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "filter" ); + expect( query.bool.filter ).toHaveKey( "bool" ); + expect( query.bool.filter.bool ).toHaveKey( "must" ); + expect( query.bool.filter.bool.must ).toBeArray().toHaveLength( 1 ); + expect( query.bool.filter.bool.must[ 1 ] ).toHaveKey( "wildcard" ); + expect( query.bool.filter.bool.must[ 1 ].wildcard ).toHaveKey( "title" ); + expect( query.bool.filter.bool.must[ 1 ].wildcard.title ).toHaveKey( "value" ); + expect( query.bool.filter.bool.must[ 1 ].wildcard.title.value ).toBe( "*test*" ); + } ); + + it( "Tests fluent must().term() direct placement", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.must().term( "status", "published" ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "must" ); + expect( query.bool.must ).toBeArray().toHaveLength( 1 ); + expect( query.bool.must[ 1 ] ).toHaveKey( "term" ); + expect( query.bool.must[ 1 ].term ).toHaveKey( "status" ); + expect( query.bool.must[ 1 ].term.status ).toBe( "published" ); + } ); + + it( "Tests fluent should().terms() with array values", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.should().terms( "status", [ "active", "published" ] ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "should" ); + expect( query.bool.should ).toBeArray().toHaveLength( 1 ); + expect( query.bool.should[ 1 ] ).toHaveKey( "terms" ); + expect( query.bool.should[ 1 ].terms ).toHaveKey( "status" ); + expect( query.bool.should[ 1 ].terms.status ).toBeArray(); + expect( arrayLen( query.bool.should[ 1 ].terms.status ) ).toBe( 2 ); + } ); + + it( "Tests fluent mustNot().exists() placement", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.mustNot().exists( "deletedAt" ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "must_not" ); + expect( query.bool.must_not ).toBeArray().toHaveLength( 1 ); + expect( query.bool.must_not[ 1 ] ).toHaveKey( "exists" ); + expect( query.bool.must_not[ 1 ].exists ).toHaveKey( "field" ); + expect( query.bool.must_not[ 1 ].exists.field ).toBe( "deletedAt" ); + } ); + + it( "Tests fluent filter().range() placement", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.filter().range( "price", gte = 10, lte = 100 ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "filter" ); + expect( query.bool.filter ).toHaveKey( "range" ); + expect( query.bool.filter.range ).toHaveKey( "price" ); + expect( query.bool.filter.range.price ).toHaveKey( "gte" ); + expect( query.bool.filter.range.price ).toHaveKey( "lte" ); + expect( query.bool.filter.range.price.gte ).toBe( 10 ); + expect( query.bool.filter.range.price.lte ).toBe( 100 ); + } ); + + it( "Tests chaining multiple fluent operations", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder + .must().term( "status", "active" ) + .should().match( "title", "test" ) + .filter().range( "price", gte = 0 ); + + var query = searchBuilder.getQuery(); + + // Verify must clause + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "must" ); + expect( query.bool.must ).toBeArray().toHaveLength( 1 ); + expect( query.bool.must[ 1 ] ).toHaveKey( "term" ); + + // Verify should clause + expect( query.bool ).toHaveKey( "should" ); + expect( query.bool.should ).toBeArray().toHaveLength( 1 ); + expect( query.bool.should[ 1 ] ).toHaveKey( "match" ); + + // Verify filter clause + expect( query.bool ).toHaveKey( "filter" ); + expect( query.bool.filter ).toHaveKey( "range" ); + } ); + + it( "Tests fluent API preserves existing SearchBuilder functionality", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + // Mix old and new API + searchBuilder.mustMatch( "title", "elasticsearch" ); + searchBuilder.must().term( "status", "active" ); + + var query = searchBuilder.getQuery(); + expect( query ).toBeStruct().toHaveKey( "bool" ); + expect( query.bool ).toHaveKey( "must" ); + expect( query.bool.must ).toBeArray().toHaveLength( 2 ); + + // First should be from mustMatch + expect( query.bool.must[ 1 ] ).toHaveKey( "match" ); + expect( query.bool.must[ 1 ].match ).toHaveKey( "title" ); + + // Second should be from fluent API + expect( query.bool.must[ 2 ] ).toHaveKey( "term" ); + expect( query.bool.must[ 2 ].term ).toHaveKey( "status" ); + } ); + + it( "Tests term query with boost parameter", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.must().term( "status", "active", 2.0 ); + + var query = searchBuilder.getQuery(); + expect( query.bool.must[ 1 ].term.status ).toBeStruct(); + expect( query.bool.must[ 1 ].term.status ).toHaveKey( "value" ); + expect( query.bool.must[ 1 ].term.status ).toHaveKey( "boost" ); + expect( query.bool.must[ 1 ].term.status.value ).toBe( "active" ); + expect( query.bool.must[ 1 ].term.status.boost ).toBe( 2.0 ); + } ); + + it( "Tests match query with boost parameter", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.should().match( "title", "elasticsearch", 1.5 ); + + var query = searchBuilder.getQuery(); + expect( query.bool.should[ 1 ].match.title ).toBeStruct(); + expect( query.bool.should[ 1 ].match.title ).toHaveKey( "query" ); + expect( query.bool.should[ 1 ].match.title ).toHaveKey( "boost" ); + expect( query.bool.should[ 1 ].match.title.query ).toBe( "elasticsearch" ); + expect( query.bool.should[ 1 ].match.title.boost ).toBe( 1.5 ); + } ); + + it( "Tests that fluent methods can be executed and return valid results", function(){ + var searchBuilder = variables.searchBuilder.new( variables.testIndexName, "testdocs" ); + + searchBuilder.must().term( "status", "nonexistent" ); + + var result = searchBuilder.execute(); + expect( result ).toBeInstanceOf( "cbElasticsearch.models.SearchResult" ); + } ); + + } ); + } + +} \ No newline at end of file