99// for ISO timestamp
1010
1111#include <limits.h>
12+ #include <stdarg.h>
1213#include <stdint.h>
1314#include "foundation/constants.h"
1415#include "foundation/arena.h"
@@ -54,9 +55,13 @@ enum {
5455 ST_GLOB_MIN_LEN = 3 ,
5556 ST_GLOB_SKIP = 2 ,
5657 ST_MAX_LANG = 10 ,
57- ST_SEARCH_MAX_BINDS = 32 , /* increased: LIKE pre-filter adds binds per pattern */
58- ST_LIKE_POOL_MAX = 12 , /* max malloc'd LIKE strings alive during one search */
59- ST_LIKE_HINT_MAX = 2 , /* max LIKE hints extracted per regex pattern */
58+ ST_SEARCH_MAX_BINDS = 48 , /* LIKE pre-filters + anchored-name range bounds add binds */
59+ ST_LIKE_POOL_MAX = 12 , /* max malloc'd LIKE strings alive during one search */
60+ ST_LIKE_HINT_MAX = 2 , /* max LIKE hints extracted per regex pattern */
61+ ST_NAME_RANGE_PREFIX = 64 , /* longest anchored name prefix seeked on */
62+ ST_NAME_RANGE_LETTERS = 3 , /* case-insensitive: letters expanded into case variants */
63+ ST_NAME_RANGE_MAX = 8 , /* 2^ST_NAME_RANGE_LETTERS name ranges (UNION ALL arms) */
64+ ST_NAME_RANGE_ARM_SQL = 96 , /* per-arm SQL beyond the select list and WHERE */
6065 ST_MAX_PKGS = 64 ,
6166 ST_INIT_CAP_4 = 4 ,
6267 ST_HEADER_PREFIX = 3 ,
@@ -6732,6 +6737,36 @@ int cbm_extract_like_hints(const char *pattern, char **out, int max_out) {
67326737 return count ;
67336738}
67346739
6740+ /* Patterns run as POSIX ERE without REG_NEWLINE, so '^' anchors at the start of
6741+ * the name only, and a pattern without '|' is one concatenation: every match
6742+ * begins with the literal run after the '^'. A following '*', '?', '{' may
6743+ * repeat the run's last character zero times and '+' may be combined with them
6744+ * (glibc reads "a+?" as "(a+)?"), so that character is dropped; so is the one
6745+ * before a backslash, which no rule here needs to interpret. */
6746+ int cbm_regex_anchored_prefix (const char * pattern , char * out , int out_sz ) {
6747+ if (!pattern || !out || out_sz <= 1 || pattern [0 ] != '^' || strchr (pattern , '|' )) {
6748+ if (out && out_sz > 0 ) {
6749+ out [0 ] = '\0' ;
6750+ }
6751+ return 0 ;
6752+ }
6753+ int len = 0 ;
6754+ const char * p = pattern + SKIP_ONE ;
6755+ while (* p && len < out_sz - SKIP_ONE ) {
6756+ unsigned char c = (unsigned char )* p ;
6757+ if (c < ' ' || c > '~' || strchr (".[]()*+?{}|^$\\" , c )) {
6758+ break ;
6759+ }
6760+ out [len ++ ] = (char )c ;
6761+ p ++ ;
6762+ }
6763+ if (len > 0 && * p && strchr ("*+?{\\" , * p )) {
6764+ len -- ;
6765+ }
6766+ out [len ] = '\0' ;
6767+ return len ;
6768+ }
6769+
67356770/* ── ensureCaseInsensitive / stripCaseFlag ────────────────────── */
67366771
67376772const char * cbm_ensure_case_insensitive (const char * pattern ) {
@@ -7028,6 +7063,151 @@ static int search_build_where(const cbm_search_params_t *params, char *where, in
70287063 return nparams ;
70297064}
70307065
7066+ /* Anchored name patterns ("^schedule$", "^kmalloc"): every matching name begins
7067+ * with the pattern's literal prefix (cbm_regex_anchored_prefix), so it lies in a
7068+ * half-open idx_nodes_name range [prefix, prefix with its last byte + 1). Under
7069+ * iregexp the prefix matches in any letter case: the process never calls
7070+ * setlocale, so the regex runs in the C locale and folds ASCII letters only, and
7071+ * each of the first ST_NAME_RANGE_LETTERS letters doubles the ranges. The ranges
7072+ * are ADDED to the full WHERE (LIKE hints and regex stay), so the rows are those
7073+ * of the plain query — which instead scans every name of the project. */
7074+ typedef struct {
7075+ char lo [ST_NAME_RANGE_MAX ][ST_NAME_RANGE_PREFIX ];
7076+ char hi [ST_NAME_RANGE_MAX ][ST_NAME_RANGE_PREFIX ];
7077+ int count ;
7078+ } search_name_ranges_t ;
7079+
7080+ static bool name_range_letter (char c ) {
7081+ return (c >= 'a' && c <= 'z' ) || (c >= 'A' && c <= 'Z' );
7082+ }
7083+
7084+ static char name_range_case (char c , bool lower ) {
7085+ if (lower && c >= 'A' && c <= 'Z' ) {
7086+ return (char )(c - 'A' + 'a' );
7087+ }
7088+ if (!lower && c >= 'a' && c <= 'z' ) {
7089+ return (char )(c - 'a' + 'A' );
7090+ }
7091+ return c ;
7092+ }
7093+
7094+ static int search_name_ranges (const cbm_search_params_t * p , search_name_ranges_t * r ) {
7095+ r -> count = 0 ;
7096+ /* Without the project the index cannot seek the name; degree filters wrap
7097+ * the query in a subselect this rewrite leaves alone. */
7098+ if (!p -> project || !p -> name_pattern || p -> min_degree >= 0 || p -> max_degree >= 0 ) {
7099+ return 0 ;
7100+ }
7101+ char prefix [ST_NAME_RANGE_PREFIX ];
7102+ int len = cbm_regex_anchored_prefix (p -> name_pattern , prefix , (int )sizeof (prefix ));
7103+ if (len == 0 ) {
7104+ return 0 ;
7105+ }
7106+ /* A pattern the regex function rejects fails the plain query once a row
7107+ * reaches the regex; fewer rows reach it here, so it keeps the plain query. */
7108+ int flags = CBM_REG_EXTENDED | CBM_REG_NOSUB | (p -> case_sensitive ? 0 : CBM_REG_ICASE );
7109+ cbm_regex_t re ;
7110+ if (cbm_regcomp (& re , p -> name_pattern , flags ) != 0 ) {
7111+ return 0 ;
7112+ }
7113+ cbm_regfree (& re );
7114+ int letters = 0 ;
7115+ if (!p -> case_sensitive ) {
7116+ int k = 0 ;
7117+ for (; k < len ; k ++ ) {
7118+ if (name_range_letter (prefix [k ])) {
7119+ if (letters == ST_NAME_RANGE_LETTERS ) {
7120+ break ;
7121+ }
7122+ letters ++ ;
7123+ }
7124+ }
7125+ len = k ;
7126+ }
7127+ int variants = SKIP_ONE << letters ;
7128+ for (int v = 0 ; v < variants ; v ++ ) {
7129+ int bit = letters - SKIP_ONE ;
7130+ for (int i = 0 ; i < len ; i ++ ) {
7131+ char c = prefix [i ];
7132+ if (!p -> case_sensitive && name_range_letter (c )) {
7133+ /* Variant 0 is all upper case: ranges in ascending byte order. */
7134+ c = name_range_case (c , ((v >> bit ) & SKIP_ONE ) != 0 );
7135+ bit -- ;
7136+ }
7137+ r -> lo [v ][i ] = c ;
7138+ r -> hi [v ][i ] = c ;
7139+ }
7140+ r -> lo [v ][len ] = '\0' ;
7141+ r -> hi [v ][len ] = '\0' ;
7142+ r -> hi [v ][len - SKIP_ONE ]++ ; /* printable ASCII: never wraps */
7143+ }
7144+ r -> count = variants ;
7145+ return variants ;
7146+ }
7147+
7148+ /* Main and count SQL over the name ranges: one UNION ALL arm per range, each
7149+ * the plain WHERE plus its range, so every arm seeks idx_nodes_name. The arms
7150+ * are disjoint and each yields (name, id) order, so ORDER BY 4, 1 (n.name,
7151+ * n.id) merges them without a sort and the per-row degree subqueries run only
7152+ * for rows the page emits; the count sums the per-range counts. On false
7153+ * (allocation, bind slots) nothing is bound and the plain query runs. */
7154+ /* Append to buf[0..cap) at *len; false (nothing written past cap) when it
7155+ * does not fit. */
7156+ static bool search_sql_append (char * buf , size_t cap , size_t * len , const char * fmt , ...) {
7157+ if (* len >= cap ) {
7158+ return false;
7159+ }
7160+ va_list args ;
7161+ va_start (args , fmt );
7162+ int wrote = vsnprintf (buf + * len , cap - * len , fmt , args );
7163+ va_end (args );
7164+ if (wrote < 0 || (size_t )wrote >= cap - * len ) {
7165+ return false;
7166+ }
7167+ * len += (size_t )wrote ;
7168+ return true;
7169+ }
7170+
7171+ static bool search_build_ranged_sql (const char * select_cols , const char * where ,
7172+ const search_name_ranges_t * r , int limit , int offset ,
7173+ search_bind_t * binds , int * bind_idx , char * * out_sql ,
7174+ char * * out_count_sql ) {
7175+ if (* bind_idx + (r -> count * ST_COL_2 ) > ST_SEARCH_MAX_BINDS ) {
7176+ return false;
7177+ }
7178+ size_t cap = (strlen (select_cols ) + strlen (where ) + ST_NAME_RANGE_ARM_SQL ) * (size_t )r -> count +
7179+ ST_NAME_RANGE_ARM_SQL ;
7180+ char * sql = cbm_alloc (CBM_MEM_CLASS_STORE , cap );
7181+ char * cnt = cbm_alloc (CBM_MEM_CLASS_STORE , cap );
7182+ int first_bind = * bind_idx ;
7183+ size_t sl = 0 ;
7184+ size_t cl = 0 ;
7185+ bool ok = sql && cnt && search_sql_append (cnt , cap , & cl , "SELECT " );
7186+ for (int i = 0 ; ok && i < r -> count ; i ++ ) {
7187+ int lo = * bind_idx + SKIP_ONE ;
7188+ where_bind_text (binds , bind_idx , r -> lo [i ]);
7189+ where_bind_text (binds , bind_idx , r -> hi [i ]);
7190+ ok = search_sql_append (sql , cap , & sl ,
7191+ "%s%s FROM nodes n WHERE (%s) AND n.name >= ?%d AND n.name < ?%d" ,
7192+ i > 0 ? " UNION ALL " : "" , select_cols , where , lo , lo + SKIP_ONE ) &&
7193+ search_sql_append (cnt , cap , & cl ,
7194+ "%s(SELECT COUNT(*) FROM nodes n WHERE (%s) AND n.name >= ?%d AND "
7195+ "n.name < ?%d)" ,
7196+ i > 0 ? " + " : "" , where , lo , lo + SKIP_ONE );
7197+ }
7198+ ok = ok && search_sql_append (sql , cap , & sl , " ORDER BY 4, 1 LIMIT %d OFFSET %d" , limit , offset );
7199+ if (!ok ) {
7200+ /* Nothing is bound: the caller runs the plain query. */
7201+ * bind_idx = first_bind ;
7202+ cbm_free (CBM_MEM_CLASS_STORE , sql );
7203+ cbm_free (CBM_MEM_CLASS_STORE , cnt );
7204+ return false;
7205+ }
7206+ * out_sql = sql ;
7207+ * out_count_sql = cnt ;
7208+ return true;
7209+ }
7210+
70317211int cbm_store_search (cbm_store_t * s , const cbm_search_params_t * params , cbm_search_output_t * out ) {
70327212 memset (out , 0 , sizeof (* out ));
70337213 if (!s || !s -> db ) {
@@ -7088,9 +7268,21 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
70887268 id_col , limit , offset );
70897269 strncat (sql , order_limit , sizeof (sql ) - strlen (sql ) - 1 );
70907270
7271+ /* Anchored name pattern: the same rows through idx_nodes_name seeks. */
7272+ search_name_ranges_t name_ranges ;
7273+ char * ranged_sql = NULL ;
7274+ char * ranged_count_sql = NULL ;
7275+ if (nparams > 0 && search_name_ranges (params , & name_ranges ) > 0 ) {
7276+ (void )search_build_ranged_sql (select_cols , where , & name_ranges , limit , offset , binds ,
7277+ & bind_idx , & ranged_sql , & ranged_count_sql );
7278+ }
7279+ const char * main_sql = ranged_sql ? ranged_sql : sql ;
7280+ const char * total_sql = ranged_count_sql ? ranged_count_sql : count_sql ;
7281+
70917282 /* Execute count query */
70927283 sqlite3_stmt * cnt_stmt = NULL ;
7093- int rc = sqlite3_prepare_v2 (s -> db , count_sql , CBM_NOT_FOUND , & cnt_stmt , NULL );
7284+ int rc = sqlite3_prepare_v2 (s -> db , total_sql , CBM_NOT_FOUND , & cnt_stmt , NULL );
7285+ cbm_free (CBM_MEM_CLASS_STORE , ranged_count_sql ); /* prepare_v2 copies the text */
70947286 if (rc == SQLITE_OK ) {
70957287 for (int i = 0 ; i < bind_idx ; i ++ ) {
70967288 bind_text (cnt_stmt , i + SKIP_ONE , binds [i ].text );
@@ -7103,7 +7295,8 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
71037295
71047296 /* Execute main query */
71057297 sqlite3_stmt * main_stmt = NULL ;
7106- rc = sqlite3_prepare_v2 (s -> db , sql , CBM_NOT_FOUND , & main_stmt , NULL );
7298+ rc = sqlite3_prepare_v2 (s -> db , main_sql , CBM_NOT_FOUND , & main_stmt , NULL );
7299+ cbm_free (CBM_MEM_CLASS_STORE , ranged_sql );
71077300 if (rc != SQLITE_OK ) {
71087301 store_set_error_sqlite (s , "search prepare" );
71097302 like_pool_free (& like_pool );
@@ -7956,6 +8149,25 @@ int cbm_store_count_edges_scoped(cbm_store_t *s, const char *project, const char
79568149 return n ;
79578150}
79588151
8152+ /* Per-label and per-type counts. GROUP BY over the (project, label) index
8153+ * decodes every entry to compare its group key; stepping through the distinct
8154+ * values with MIN(col) > previous seeks and counting each value's index range
8155+ * is ~1.6x faster (kernel, 8.5M nodes: 0.26 -> 0.16 s; 16.9M edges: 0.54 ->
8156+ * 0.32 s). The values reach ORDER BY in the ascending order GROUP BY emitted
8157+ * them in, so tied counts keep their order. label and type are NOT NULL. */
8158+ static const char ST_LABEL_COUNTS_SQL [] =
8159+ "WITH RECURSIVE v(x) AS (SELECT MIN(label) FROM nodes WHERE project = ?1 UNION ALL "
8160+ "SELECT (SELECT MIN(label) FROM nodes WHERE project = ?1 AND label > v.x) FROM v "
8161+ "WHERE v.x IS NOT NULL) "
8162+ "SELECT x, (SELECT COUNT(*) FROM nodes WHERE project = ?1 AND label = v.x) AS c "
8163+ "FROM v WHERE x IS NOT NULL ORDER BY c DESC;" ;
8164+ static const char ST_TYPE_COUNTS_SQL [] =
8165+ "WITH RECURSIVE v(x) AS (SELECT MIN(type) FROM edges WHERE project = ?1 UNION ALL "
8166+ "SELECT (SELECT MIN(type) FROM edges WHERE project = ?1 AND type > v.x) FROM v "
8167+ "WHERE v.x IS NOT NULL) "
8168+ "SELECT x, (SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = v.x) AS c "
8169+ "FROM v WHERE x IS NOT NULL ORDER BY c DESC;" ;
8170+
79598171/* with_props=false skips the per-label/per-type JSON property-key discovery:
79608172 * those json_each() scans walk EVERY row of each label/type (minutes-scale on
79618173 * multi-million-node graphs) and get_architecture only needs the counts. */
@@ -7968,8 +8180,7 @@ static int get_schema_impl(cbm_store_t *s, const char *project, cbm_schema_info_
79688180
79698181 /* Node labels */
79708182 {
7971- const char * sql = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1 GROUP BY label "
7972- "ORDER BY COUNT(*) DESC;" ;
8183+ const char * sql = ST_LABEL_COUNTS_SQL ;
79738184 sqlite3_stmt * stmt = NULL ;
79748185 if (sqlite3_prepare_v2 (s -> db , sql , CBM_NOT_FOUND , & stmt , NULL ) != SQLITE_OK || !stmt ) {
79758186 if (stmt ) {
@@ -8041,8 +8252,7 @@ static int get_schema_impl(cbm_store_t *s, const char *project, cbm_schema_info_
80418252
80428253 /* Edge types */
80438254 {
8044- const char * sql = "SELECT type, COUNT(*) FROM edges WHERE project = ?1 GROUP BY type ORDER "
8045- "BY COUNT(*) DESC;" ;
8255+ const char * sql = ST_TYPE_COUNTS_SQL ;
80468256 sqlite3_stmt * stmt = NULL ;
80478257 if (sqlite3_prepare_v2 (s -> db , sql , CBM_NOT_FOUND , & stmt , NULL ) != SQLITE_OK || !stmt ) {
80488258 if (stmt ) {
@@ -9013,15 +9223,24 @@ static int arch_packages(cbm_store_t *s, const char *project, const char *path,
90139223 char like [CBM_SZ_512 ];
90149224 bool scoped = arch_path_prepare (path , norm , sizeof (norm ), like , sizeof (like ));
90159225 char sqlbuf [ST_SQL_BUF ];
9226+ /* GROUP BY +n.name, not n.name: with a bare column the planner walks
9227+ * idx_nodes_name over EVERY node of the project to get groups in name order,
9228+ * with a table lookup per node to test the label (6.3 s on the 8.5M-node
9229+ * kernel graph, for 5 Package nodes). The unary plus keeps the value and its
9230+ * BINARY collation but not the index, so the rows come from the
9231+ * idx_nodes_label seek. The ORDER BY sorter keeps tied counts in group
9232+ * emission order, which was name order from the index; a sorted GROUP BY
9233+ * follows the ORDER BY direction instead, so the name tie-break is explicit. */
90169234 const char * base = "SELECT n.name, COUNT(*) as cnt FROM nodes n "
90179235 "WHERE n.project=?1 AND n.label='Package'" ;
90189236 if (scoped ) {
90199237 snprintf (sqlbuf , sizeof (sqlbuf ),
90209238 "%s AND (n.file_path = ?2 OR n.file_path LIKE ?3) "
9021- "GROUP BY n.name ORDER BY cnt DESC LIMIT 15" ,
9239+ "GROUP BY + n.name ORDER BY cnt DESC, +n.name LIMIT 15" ,
90229240 base );
90239241 } else {
9024- snprintf (sqlbuf , sizeof (sqlbuf ), "%s GROUP BY n.name ORDER BY cnt DESC LIMIT 15" , base );
9242+ snprintf (sqlbuf , sizeof (sqlbuf ), "%s GROUP BY +n.name ORDER BY cnt DESC, +n.name LIMIT 15" ,
9243+ base );
90259244 }
90269245 sqlite3_stmt * stmt = NULL ;
90279246 if (sqlite3_prepare_v2 (s -> db , sqlbuf , CBM_NOT_FOUND , & stmt , NULL ) != SQLITE_OK ) {
0 commit comments