Skip to content

Commit f30c78e

Browse files
Pierre-Luc Gagnéclaude
andcommitted
feat: add NOT REGEXP, RLIKE, SOUNDS LIKE, and MATCH...AGAINST predicates
- not_regexp<Col>(pattern) — col NOT REGEXP 'pattern' - rlike<Col>(pattern) — col RLIKE 'pattern' (MySQL synonym for REGEXP) - not_rlike<Col>(pattern) — col NOT RLIKE 'pattern' - sounds_like<Col>(word) — col SOUNDS LIKE 'word' - match_against<Cols...>(expr [, modifier]) — full-text search with boolean_mode, query_expansion, and natural_language_with_query_expansion modifiers via the new match_search_modifier enum - All predicates available as col_ref<Col> methods (except match_against) - MySQL alias free functions: mysql_not_regexp, mysql_rlike, mysql_not_rlike Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 018bc78 commit f30c78e

4 files changed

Lines changed: 285 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- `not_regexp<Col>(pattern)``col NOT REGEXP 'pattern'`
14+
- `rlike<Col>(pattern)``col RLIKE 'pattern'` (MySQL synonym for `REGEXP`)
15+
- `not_rlike<Col>(pattern)``col NOT RLIKE 'pattern'`
16+
- `sounds_like<Col>(word)``col SOUNDS LIKE 'word'`
17+
- `match_against<Cols...>(expr [, modifier])` — full-text search via `MATCH(...) AGAINST (...)`,
18+
supporting `match_search_modifier::boolean_mode`, `query_expansion`, and
19+
`natural_language_with_query_expansion`
20+
- All of the above are also available as methods on `col_expr` / `col_ref<Col>` (except `match_against`)
21+
- MySQL alias free functions: `mysql_not_regexp`, `mysql_rlike`, `mysql_not_rlike`
22+
1123
---
1224

1325
## [2.1.1] – 2026-03-24

lib/include/ds_mysql/sql_core.hpp

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ enum class sql_cast_type {
5757

5858
enum class sort_order { asc, desc };
5959

60+
enum class match_search_modifier {
61+
natural_language, // MATCH(...) AGAINST ('expr') — default, no modifier emitted
62+
boolean_mode, // MATCH(...) AGAINST ('expr' IN BOOLEAN MODE)
63+
query_expansion, // MATCH(...) AGAINST ('expr' WITH QUERY EXPANSION)
64+
natural_language_with_query_expansion, // MATCH(...) AGAINST ('expr' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)
65+
};
66+
6067
template <typename... Cols>
6168
struct grouping_set {};
6269

@@ -292,6 +299,13 @@ struct where_condition {
292299
// is_null<Col>() — col IS NULL
293300
// is_not_null<Col>() — col IS NOT NULL
294301
// like<Col>(pattern) — col LIKE 'pattern'
302+
// not_like<Col>(pattern) — col NOT LIKE 'pattern'
303+
// regexp<Col>(pattern) — col REGEXP 'pattern'
304+
// not_regexp<Col>(pattern) — col NOT REGEXP 'pattern'
305+
// rlike<Col>(pattern) — col RLIKE 'pattern' (MySQL synonym for REGEXP)
306+
// not_rlike<Col>(pattern) — col NOT RLIKE 'pattern'
307+
// sounds_like<Col>(word) — col SOUNDS LIKE 'word'
308+
// match_against<C1,C2,...>(expr) — MATCH(c1,c2,...) AGAINST ('expr')
295309
// and_(a, b) — (a AND b)
296310
// or_(a, b) — (a OR b)
297311
// not_(cond) — NOT (cond)
@@ -513,6 +527,83 @@ template <ColumnFieldType Col>
513527
return {column_traits<Col>::column_name(), " REGEXP ", std::move(rhs)};
514528
}
515529

530+
// not_regexp<Col>(pattern) — col NOT REGEXP 'pattern'
531+
template <ColumnFieldType Col>
532+
[[nodiscard]] where_condition not_regexp(std::string_view pattern) {
533+
auto escaped = sql_detail::escape_sql_string(pattern);
534+
std::string rhs;
535+
rhs.reserve(2 + escaped.size());
536+
rhs += '\'';
537+
rhs += std::move(escaped);
538+
rhs += '\'';
539+
return {column_traits<Col>::column_name(), " NOT REGEXP ", std::move(rhs)};
540+
}
541+
542+
// rlike<Col>(pattern) — col RLIKE 'pattern' (MySQL synonym for REGEXP)
543+
template <ColumnFieldType Col>
544+
[[nodiscard]] where_condition rlike(std::string_view pattern) {
545+
return regexp<Col>(pattern);
546+
}
547+
548+
// not_rlike<Col>(pattern) — col NOT RLIKE 'pattern'
549+
template <ColumnFieldType Col>
550+
[[nodiscard]] where_condition not_rlike(std::string_view pattern) {
551+
return not_regexp<Col>(pattern);
552+
}
553+
554+
// sounds_like<Col>(word) — col SOUNDS LIKE 'word'
555+
template <ColumnFieldType Col>
556+
[[nodiscard]] where_condition sounds_like(std::string_view word) {
557+
auto escaped = sql_detail::escape_sql_string(word);
558+
std::string rhs;
559+
rhs.reserve(2 + escaped.size());
560+
rhs += '\'';
561+
rhs += std::move(escaped);
562+
rhs += '\'';
563+
return {column_traits<Col>::column_name(), " SOUNDS LIKE ", std::move(rhs)};
564+
}
565+
566+
// match_against<Cols...>(expr [, modifier]) — MATCH(col1, col2, ...) AGAINST ('expr' [modifier])
567+
//
568+
// modifier is one of:
569+
// match_search_modifier::natural_language (default) — no modifier emitted
570+
// match_search_modifier::boolean_mode — IN BOOLEAN MODE
571+
// match_search_modifier::query_expansion — WITH QUERY EXPANSION
572+
// match_search_modifier::natural_language_with_query_expansion — IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION
573+
template <ColumnFieldType... Cols>
574+
requires(sizeof...(Cols) > 0)
575+
[[nodiscard]] where_condition match_against(std::string_view expr,
576+
match_search_modifier modifier = match_search_modifier::natural_language) {
577+
std::string s;
578+
s.reserve(64 + expr.size());
579+
s += "MATCH(";
580+
bool first = true;
581+
auto add_col = [&](std::string_view name) {
582+
if (!first) s += ", ";
583+
s += name;
584+
first = false;
585+
};
586+
(add_col(column_traits<Cols>::column_name()), ...);
587+
s += ") AGAINST ('";
588+
s += sql_detail::escape_sql_string(expr);
589+
s += '\'';
590+
switch (modifier) {
591+
case match_search_modifier::boolean_mode:
592+
s += " IN BOOLEAN MODE";
593+
break;
594+
case match_search_modifier::query_expansion:
595+
s += " WITH QUERY EXPANSION";
596+
break;
597+
case match_search_modifier::natural_language_with_query_expansion:
598+
s += " IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION";
599+
break;
600+
case match_search_modifier::natural_language:
601+
break;
602+
}
603+
s += ')';
604+
return {{}, {}, std::move(s)};
605+
}
606+
516607
template <ColumnFieldType Col, AnySelectQuery Query>
517608
[[nodiscard]] where_condition in_subquery(Query const& subquery) {
518609
auto sub = subquery.build_sql();
@@ -575,9 +666,14 @@ template <AnySelectQuery Query>
575666
// col_ref<trade::name>.is_not_null() → is_not_null<trade::name>()
576667
// col_ref<trade::code>.like("AAPL%") → like<trade::code>("AAPL%")
577668
// col_ref<trade::code>.not_like("X%") → not_like<trade::code>("X%")
578-
// col_ref<trade::id>.between(1u, 10u) → between<trade::id>(1u, 10u)
579-
// col_ref<trade::code>.in({"AAPL", "GOOGL"}) → in<trade::code>({"AAPL", "GOOGL"})
580-
// col_ref<trade::code>.not_in({"X", "Y"}) → not_in<trade::code>({"X", "Y"})
669+
// col_ref<trade::id>.between(1u, 10u) → between<trade::id>(1u, 10u)
670+
// col_ref<trade::code>.in({"AAPL", "GOOGL"}) → in<trade::code>({"AAPL", "GOOGL"})
671+
// col_ref<trade::code>.not_in({"X", "Y"}) → not_in<trade::code>({"X", "Y"})
672+
// col_ref<trade::code>.regexp("^A") → regexp<trade::code>("^A")
673+
// col_ref<trade::code>.not_regexp("^A") → not_regexp<trade::code>("^A")
674+
// col_ref<trade::code>.rlike("^A") → rlike<trade::code>("^A")
675+
// col_ref<trade::code>.not_rlike("^A") → not_rlike<trade::code>("^A")
676+
// col_ref<trade::name>.sounds_like("word") → sounds_like<trade::name>("word")
581677
//
582678
// Composing with &, |, !:
583679
// col_ref<trade::code> == "AAPL" | col_ref<trade::code> == "GOOGL"
@@ -646,6 +742,18 @@ struct col_expr {
646742
[[nodiscard]] where_condition regexp(std::string_view pattern) const {
647743
return ds_mysql::regexp<Col>(pattern);
648744
}
745+
[[nodiscard]] where_condition not_regexp(std::string_view pattern) const {
746+
return ds_mysql::not_regexp<Col>(pattern);
747+
}
748+
[[nodiscard]] where_condition rlike(std::string_view pattern) const {
749+
return ds_mysql::rlike<Col>(pattern);
750+
}
751+
[[nodiscard]] where_condition not_rlike(std::string_view pattern) const {
752+
return ds_mysql::not_rlike<Col>(pattern);
753+
}
754+
[[nodiscard]] where_condition sounds_like(std::string_view word) const {
755+
return ds_mysql::sounds_like<Col>(word);
756+
}
649757

650758
[[nodiscard]] where_condition null_safe_equal(value_type const& val) const {
651759
return ds_mysql::null_safe_equal<Col>(Col{val});

lib/include/ds_mysql/sql_dql.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2799,4 +2799,19 @@ template <ColumnDescriptor Col>
27992799
return regexp<Col>(pattern);
28002800
}
28012801

2802+
template <ColumnDescriptor Col>
2803+
[[nodiscard]] where_condition mysql_not_regexp(std::string_view pattern) {
2804+
return not_regexp<Col>(pattern);
2805+
}
2806+
2807+
template <ColumnDescriptor Col>
2808+
[[nodiscard]] where_condition mysql_rlike(std::string_view pattern) {
2809+
return rlike<Col>(pattern);
2810+
}
2811+
2812+
template <ColumnDescriptor Col>
2813+
[[nodiscard]] where_condition mysql_not_rlike(std::string_view pattern) {
2814+
return not_rlike<Col>(pattern);
2815+
}
2816+
28022817
} // namespace ds_mysql

tests/unit/test_dql.cpp

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,3 +1945,150 @@ suite<"DML Features"> dml_features_suite = [] {
19451945
expect(sql == "REPLACE INTO ext_product (id, category_id, sku, type, name, price_val) VALUES ()") << sql;
19461946
};
19471947
};
1948+
1949+
// ===================================================================
1950+
// NOT REGEXP / RLIKE / NOT RLIKE
1951+
// ===================================================================
1952+
1953+
suite<"DQL NOT REGEXP / RLIKE Predicates"> dql_not_regexp_rlike_suite = [] {
1954+
"not_regexp<Col>(pattern) — free function generates NOT REGEXP SQL"_test = [] {
1955+
auto const cond = not_regexp<ext_product::sku>("^A.*");
1956+
expect(cond.build_sql() == "sku NOT REGEXP '^A.*'"s) << cond.build_sql();
1957+
};
1958+
1959+
"col_ref.not_regexp() — method on col_expr"_test = [] {
1960+
auto const cond = col_ref<ext_product::sku>.not_regexp("^WIDGET");
1961+
expect(cond.build_sql() == "sku NOT REGEXP '^WIDGET'"s) << cond.build_sql();
1962+
};
1963+
1964+
"not_regexp in WHERE clause"_test = [] {
1965+
auto const sql = select<ext_product::id, ext_product::sku>()
1966+
.from<ext_product>()
1967+
.where(not_regexp<ext_product::sku>("^[A-Z]"))
1968+
.build_sql();
1969+
expect(sql == "SELECT id, sku FROM ext_product WHERE sku NOT REGEXP '^[A-Z]'"s) << sql;
1970+
};
1971+
1972+
"not_regexp escapes single-quote in pattern"_test = [] {
1973+
auto const cond = not_regexp<ext_product::sku>("it's");
1974+
expect(cond.build_sql() == "sku NOT REGEXP 'it''s'"s) << cond.build_sql();
1975+
};
1976+
1977+
"rlike<Col>(pattern) — generates REGEXP SQL (MySQL synonym)"_test = [] {
1978+
auto const cond = rlike<ext_product::sku>("^RLIKE");
1979+
expect(cond.build_sql() == "sku REGEXP '^RLIKE'"s) << cond.build_sql();
1980+
};
1981+
1982+
"col_ref.rlike() — method on col_expr"_test = [] {
1983+
auto const cond = col_ref<ext_product::sku>.rlike("^R");
1984+
expect(cond.build_sql() == "sku REGEXP '^R'"s) << cond.build_sql();
1985+
};
1986+
1987+
"not_rlike<Col>(pattern) — generates NOT REGEXP SQL"_test = [] {
1988+
auto const cond = not_rlike<ext_product::sku>("^X");
1989+
expect(cond.build_sql() == "sku NOT REGEXP '^X'"s) << cond.build_sql();
1990+
};
1991+
1992+
"col_ref.not_rlike() — method on col_expr"_test = [] {
1993+
auto const cond = col_ref<ext_product::sku>.not_rlike("^X");
1994+
expect(cond.build_sql() == "sku NOT REGEXP '^X'"s) << cond.build_sql();
1995+
};
1996+
1997+
"mysql_not_regexp alias — generates NOT REGEXP SQL"_test = [] {
1998+
auto const cond = mysql_not_regexp<ext_product::sku>("^A");
1999+
expect(cond.build_sql() == "sku NOT REGEXP '^A'"s) << cond.build_sql();
2000+
};
2001+
2002+
"mysql_rlike alias — generates REGEXP SQL"_test = [] {
2003+
auto const cond = mysql_rlike<ext_product::sku>("^M");
2004+
expect(cond.build_sql() == "sku REGEXP '^M'"s) << cond.build_sql();
2005+
};
2006+
2007+
"mysql_not_rlike alias — generates NOT REGEXP SQL"_test = [] {
2008+
auto const cond = mysql_not_rlike<ext_product::sku>("^M");
2009+
expect(cond.build_sql() == "sku NOT REGEXP '^M'"s) << cond.build_sql();
2010+
};
2011+
};
2012+
2013+
// ===================================================================
2014+
// SOUNDS LIKE
2015+
// ===================================================================
2016+
2017+
suite<"DQL SOUNDS LIKE Predicate"> dql_sounds_like_suite = [] {
2018+
"sounds_like<Col>(word) — free function generates SOUNDS LIKE SQL"_test = [] {
2019+
auto const cond = sounds_like<ext_product::sku>("widget");
2020+
expect(cond.build_sql() == "sku SOUNDS LIKE 'widget'"s) << cond.build_sql();
2021+
};
2022+
2023+
"col_ref.sounds_like() — method on col_expr"_test = [] {
2024+
auto const cond = col_ref<ext_product::sku>.sounds_like("widget");
2025+
expect(cond.build_sql() == "sku SOUNDS LIKE 'widget'"s) << cond.build_sql();
2026+
};
2027+
2028+
"sounds_like in WHERE clause"_test = [] {
2029+
auto const sql = select<ext_product::id, ext_product::sku>()
2030+
.from<ext_product>()
2031+
.where(sounds_like<ext_product::sku>("widget"))
2032+
.build_sql();
2033+
expect(sql == "SELECT id, sku FROM ext_product WHERE sku SOUNDS LIKE 'widget'"s) << sql;
2034+
};
2035+
2036+
"sounds_like escapes single-quote in word"_test = [] {
2037+
auto const cond = sounds_like<ext_product::sku>("it's");
2038+
expect(cond.build_sql() == "sku SOUNDS LIKE 'it''s'"s) << cond.build_sql();
2039+
};
2040+
};
2041+
2042+
// ===================================================================
2043+
// MATCH ... AGAINST
2044+
// ===================================================================
2045+
2046+
suite<"DQL MATCH AGAINST Predicate"> dql_match_against_suite = [] {
2047+
"match_against single column — default natural language mode"_test = [] {
2048+
auto const cond = match_against<ext_product::sku>("widget");
2049+
expect(cond.build_sql() == "MATCH(sku) AGAINST ('widget')"s) << cond.build_sql();
2050+
};
2051+
2052+
"match_against multiple columns — default natural language mode"_test = [] {
2053+
auto const cond = match_against<ext_product::sku, ext_product::type>("blue widget");
2054+
expect(cond.build_sql() == "MATCH(sku, type) AGAINST ('blue widget')"s) << cond.build_sql();
2055+
};
2056+
2057+
"match_against IN BOOLEAN MODE"_test = [] {
2058+
auto const cond = match_against<ext_product::sku>("widget", match_search_modifier::boolean_mode);
2059+
expect(cond.build_sql() == "MATCH(sku) AGAINST ('widget' IN BOOLEAN MODE)"s) << cond.build_sql();
2060+
};
2061+
2062+
"match_against IN NATURAL LANGUAGE MODE — explicit modifier"_test = [] {
2063+
auto const cond = match_against<ext_product::sku>("widget", match_search_modifier::natural_language);
2064+
expect(cond.build_sql() == "MATCH(sku) AGAINST ('widget')"s) << cond.build_sql();
2065+
};
2066+
2067+
"match_against WITH QUERY EXPANSION"_test = [] {
2068+
auto const cond = match_against<ext_product::sku>("widget", match_search_modifier::query_expansion);
2069+
expect(cond.build_sql() == "MATCH(sku) AGAINST ('widget' WITH QUERY EXPANSION)"s) << cond.build_sql();
2070+
};
2071+
2072+
"match_against IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION"_test = [] {
2073+
auto const cond =
2074+
match_against<ext_product::sku>("widget", match_search_modifier::natural_language_with_query_expansion);
2075+
expect(cond.build_sql() == "MATCH(sku) AGAINST ('widget' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)"s)
2076+
<< cond.build_sql();
2077+
};
2078+
2079+
"match_against escapes single-quote in expression"_test = [] {
2080+
auto const cond = match_against<ext_product::sku>("it's");
2081+
expect(cond.build_sql() == "MATCH(sku) AGAINST ('it''s')"s) << cond.build_sql();
2082+
};
2083+
2084+
"match_against boolean mode in WHERE clause"_test = [] {
2085+
auto const sql = select<ext_product::id, ext_product::sku>()
2086+
.from<ext_product>()
2087+
.where(match_against<ext_product::sku, ext_product::type>("+widget -cheap",
2088+
match_search_modifier::boolean_mode))
2089+
.build_sql();
2090+
expect(sql ==
2091+
"SELECT id, sku FROM ext_product WHERE MATCH(sku, type) AGAINST ('+widget -cheap' IN BOOLEAN MODE)"s)
2092+
<< sql;
2093+
};
2094+
};

0 commit comments

Comments
 (0)