Skip to content

Commit dc1286a

Browse files
swalkinshawretlehsclaude
authored
DB query optimizations (#5)
* Use FTS and precompute aggregate package stats Avoids expensive aggregate calculations and FTS is much faster for text search. * Fix NULL crash on empty DB and search regression for collapsed slugs Wrap active_plugins/active_themes SUM in COALESCE to prevent NOT NULL constraint failures when no active packages exist. Add LIKE fallback for slug searches so compact queries like "woocommerceblocks" still match hyphenated names like "woo-commerce-blocks". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix migration numbering and keep package stats fresh --------- Co-authored-by: Ben Word <ben@benword.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d207658 commit dc1286a

File tree

11 files changed

+302
-28
lines changed

11 files changed

+302
-28
lines changed

cmd/wpcomposer/cmd/dev.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ func runDev(cmd *cobra.Command, args []string) error {
128128
_ = packages.FinishSyncRun(ctx, application.DB, syncRun.RowID, "completed", map[string]any{
129129
"updated": succeeded.Load(), "failed": failed.Load(),
130130
})
131+
if err := packages.RefreshSiteStats(ctx, application.DB); err != nil {
132+
return fmt.Errorf("refreshing package stats: %w", err)
133+
}
131134
application.Logger.Info("dev: metadata fetched", "updated", succeeded.Load(), "failed", failed.Load())
132135

133136
// 5. Build

cmd/wpcomposer/cmd/discover.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ func discoverFromConfig(ctx context.Context, pkgType string, limit, concurrency
102102
}
103103

104104
application.Logger.Info("discovery complete", "succeeded", succeeded.Load(), "failed", failed.Load())
105+
if err := packages.RefreshSiteStats(ctx, application.DB); err != nil {
106+
return fmt.Errorf("refreshing package stats: %w", err)
107+
}
105108
if failed.Load() > 0 {
106109
return fmt.Errorf("discovery completed with %d failures", failed.Load())
107110
}
@@ -183,6 +186,10 @@ func discoverFromSVN(ctx context.Context, pkgType string, limit int) error {
183186
}
184187
}
185188

189+
if err := packages.RefreshSiteStats(ctx, application.DB); err != nil {
190+
return fmt.Errorf("refreshing package stats: %w", err)
191+
}
192+
186193
if totalFailed > 0 {
187194
return fmt.Errorf("SVN discovery completed with %d failures", totalFailed)
188195
}

cmd/wpcomposer/cmd/update.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ func runUpdate(cmd *cobra.Command, args []string) error {
196196
return fmt.Errorf("finishing sync run: %w", err)
197197
}
198198

199+
if err := packages.RefreshSiteStats(ctx, application.DB); err != nil {
200+
return fmt.Errorf("refreshing package stats: %w", err)
201+
}
202+
199203
application.Logger.Info("update complete",
200204
"updated", succeeded.Load(),
201205
"failed", failed.Load(),

internal/db/migrate_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package db
2+
3+
import (
4+
"database/sql"
5+
"testing"
6+
7+
wpcomposergo "github.com/roots/wp-composer"
8+
)
9+
10+
func TestMigrateCreatesPackageStatsAndFTS(t *testing.T) {
11+
dbPath := t.TempDir() + "/test.db"
12+
database, err := Open(dbPath)
13+
if err != nil {
14+
t.Fatalf("opening db: %v", err)
15+
}
16+
t.Cleanup(func() { _ = database.Close() })
17+
18+
if err := Migrate(database, wpcomposergo.Migrations); err != nil {
19+
t.Fatalf("running migrations: %v", err)
20+
}
21+
22+
assertObjectExists(t, database, "table", "package_stats")
23+
assertObjectExists(t, database, "table", "packages_fts")
24+
assertObjectExists(t, database, "trigger", "packages_fts_insert")
25+
assertObjectExists(t, database, "trigger", "packages_fts_update")
26+
assertObjectExists(t, database, "trigger", "packages_fts_delete")
27+
}
28+
29+
func assertObjectExists(t *testing.T, database *sql.DB, objType, name string) {
30+
t.Helper()
31+
var count int
32+
err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = ? AND name = ?`, objType, name).Scan(&count)
33+
if err != nil {
34+
t.Fatalf("querying sqlite_master for %s %s: %v", objType, name, err)
35+
}
36+
if count != 1 {
37+
t.Fatalf("expected %s %s to exist", objType, name)
38+
}
39+
}

internal/http/handlers.go

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -537,20 +537,40 @@ type indexStats struct {
537537

538538
func queryIndexStats(ctx context.Context, db *sql.DB) indexStats {
539539
var s indexStats
540-
_ = db.QueryRowContext(ctx, "SELECT COALESCE(SUM(wp_composer_installs_total), 0) FROM packages WHERE type = 'plugin'").Scan(&s.PluginInstalls)
541-
_ = db.QueryRowContext(ctx, "SELECT COALESCE(SUM(wp_composer_installs_total), 0) FROM packages WHERE type = 'theme'").Scan(&s.ThemeInstalls)
540+
_ = db.QueryRowContext(ctx, "SELECT plugin_installs, theme_installs FROM package_stats WHERE id = 1").Scan(&s.PluginInstalls, &s.ThemeInstalls)
542541
return s
543542
}
544543

544+
// collapseSlug strips hyphens, underscores, and spaces to produce a
545+
// compact form suitable for LIKE-matching against similarly collapsed names.
546+
func collapseSlug(s string) string {
547+
s = strings.ToLower(s)
548+
s = strings.ReplaceAll(s, "-", "")
549+
s = strings.ReplaceAll(s, "_", "")
550+
s = strings.ReplaceAll(s, " ", "")
551+
return s
552+
}
553+
554+
// ftsQuery converts a user search string into an FTS5 query.
555+
// Each token becomes a prefix search, joined with AND.
556+
// e.g. "woo commerce" -> "woo* AND commerce*"
557+
func ftsQuery(s string) string {
558+
words := strings.Fields(s)
559+
for i, w := range words {
560+
// Escape double quotes to prevent FTS5 syntax injection
561+
w = strings.ReplaceAll(w, `"`, `""`)
562+
words[i] = `"` + w + `"` + "*"
563+
}
564+
return strings.Join(words, " AND ")
565+
}
566+
545567
func queryPackages(ctx context.Context, db *sql.DB, f publicFilters, page, limit int) ([]packageRow, int, error) {
546568
where := "is_active = 1"
547569
args := []any{}
548570

549-
if f.Search != "" {
550-
normalized := "%" + strings.NewReplacer("-", "", " ", "").Replace(f.Search) + "%"
551-
s := "%" + f.Search + "%"
552-
where += " AND (REPLACE(REPLACE(name, '-', ''), ' ', '') LIKE ? OR display_name LIKE ? OR description LIKE ?)"
553-
args = append(args, normalized, s, s)
571+
if q := ftsQuery(f.Search); q != "" {
572+
where += " AND (id IN (SELECT rowid FROM packages_fts WHERE packages_fts MATCH ?) OR REPLACE(name, '-', '') LIKE ?)"
573+
args = append(args, q, "%"+collapseSlug(f.Search)+"%")
554574
}
555575
if f.Type != "" {
556576
where += " AND type = ?"
@@ -568,9 +588,13 @@ func queryPackages(ctx context.Context, db *sql.DB, f publicFilters, page, limit
568588
}
569589

570590
var total int
571-
countQ := "SELECT COUNT(*) FROM packages WHERE " + where
572-
if err := db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
573-
return nil, 0, err
591+
if f.Search == "" && f.Type == "" {
592+
_ = db.QueryRowContext(ctx, "SELECT active_plugins + active_themes FROM package_stats WHERE id = 1").Scan(&total)
593+
} else {
594+
countQ := "SELECT COUNT(*) FROM packages WHERE " + where
595+
if err := db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
596+
return nil, 0, err
597+
}
574598
}
575599

576600
offset := (page - 1) * limit
@@ -693,11 +717,9 @@ func queryDashboardStats(ctx context.Context, db *sql.DB) map[string]any {
693717
CurrentBuild string
694718
}
695719

696-
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM packages WHERE is_active = 1").Scan(&s.TotalPackages)
697-
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM packages WHERE is_active = 1 AND type = 'plugin'").Scan(&s.ActivePlugins)
698-
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM packages WHERE is_active = 1 AND type = 'theme'").Scan(&s.ActiveThemes)
699-
_ = db.QueryRowContext(ctx, "SELECT COALESCE(SUM(wp_composer_installs_total), 0) FROM packages").Scan(&s.TotalInstalls)
700-
_ = db.QueryRowContext(ctx, "SELECT COALESCE(SUM(wp_composer_installs_30d), 0) FROM packages").Scan(&s.Installs30d)
720+
_ = db.QueryRowContext(ctx, `SELECT active_plugins, active_themes, active_plugins + active_themes,
721+
plugin_installs + theme_installs, installs_30d FROM package_stats WHERE id = 1`).Scan(
722+
&s.ActivePlugins, &s.ActiveThemes, &s.TotalPackages, &s.TotalInstalls, &s.Installs30d)
701723

702724
stats["Stats"] = s
703725
return stats
@@ -707,11 +729,9 @@ func queryAdminPackages(ctx context.Context, db *sql.DB, f adminFilters, page, l
707729
where := "1=1"
708730
args := []any{}
709731

710-
if f.Search != "" {
711-
normalized := "%" + strings.NewReplacer("-", "", " ", "").Replace(f.Search) + "%"
712-
s := "%" + f.Search + "%"
713-
where += " AND (REPLACE(REPLACE(name, '-', ''), ' ', '') LIKE ? OR display_name LIKE ? OR description LIKE ?)"
714-
args = append(args, normalized, s, s)
732+
if q := ftsQuery(f.Search); q != "" {
733+
where += " AND (id IN (SELECT rowid FROM packages_fts WHERE packages_fts MATCH ?) OR REPLACE(name, '-', '') LIKE ?)"
734+
args = append(args, q, "%"+collapseSlug(f.Search)+"%")
715735
}
716736
if f.Type != "" {
717737
where += " AND type = ?"

internal/packages/package.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,25 @@ func DeactivatePackage(ctx context.Context, db *sql.DB, id int64) error {
337337
return nil
338338
}
339339

340+
// RefreshSiteStats recomputes the package_stats row from the packages table.
341+
func RefreshSiteStats(ctx context.Context, db *sql.DB) error {
342+
_, err := db.ExecContext(ctx, `
343+
INSERT OR REPLACE INTO package_stats (id, active_plugins, active_themes, plugin_installs, theme_installs, installs_30d, updated_at)
344+
SELECT 1,
345+
COALESCE(SUM(CASE WHEN type = 'plugin' THEN 1 ELSE 0 END), 0),
346+
COALESCE(SUM(CASE WHEN type = 'theme' THEN 1 ELSE 0 END), 0),
347+
COALESCE(SUM(CASE WHEN type = 'plugin' THEN wp_composer_installs_total ELSE 0 END), 0),
348+
COALESCE(SUM(CASE WHEN type = 'theme' THEN wp_composer_installs_total ELSE 0 END), 0),
349+
COALESCE(SUM(wp_composer_installs_30d), 0),
350+
datetime('now')
351+
FROM packages
352+
WHERE is_active = 1`)
353+
if err != nil {
354+
return fmt.Errorf("refreshing package stats: %w", err)
355+
}
356+
return nil
357+
}
358+
340359
func boolToInt(b bool) int {
341360
if b {
342361
return 1

internal/packages/package_test.go

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,23 @@ func setupTestDB(t *testing.T) *sql.DB {
4646
updated_at TEXT NOT NULL,
4747
UNIQUE(type, name)
4848
);
49-
CREATE TABLE sync_runs (
50-
id INTEGER PRIMARY KEY,
51-
started_at TEXT NOT NULL,
52-
finished_at TEXT,
53-
status TEXT NOT NULL,
54-
meta_json TEXT NOT NULL DEFAULT '{}'
55-
);
56-
`)
49+
CREATE TABLE sync_runs (
50+
id INTEGER PRIMARY KEY,
51+
started_at TEXT NOT NULL,
52+
finished_at TEXT,
53+
status TEXT NOT NULL,
54+
meta_json TEXT NOT NULL DEFAULT '{}'
55+
);
56+
CREATE TABLE package_stats (
57+
id INTEGER PRIMARY KEY CHECK (id = 1),
58+
active_plugins INTEGER NOT NULL DEFAULT 0,
59+
active_themes INTEGER NOT NULL DEFAULT 0,
60+
plugin_installs INTEGER NOT NULL DEFAULT 0,
61+
theme_installs INTEGER NOT NULL DEFAULT 0,
62+
installs_30d INTEGER NOT NULL DEFAULT 0,
63+
updated_at TEXT NOT NULL DEFAULT ''
64+
);
65+
`)
5766
if err != nil {
5867
t.Fatalf("creating tables: %v", err)
5968
}
@@ -172,6 +181,89 @@ func TestDeactivatePackage(t *testing.T) {
172181
}
173182
}
174183

184+
func TestRefreshSiteStats(t *testing.T) {
185+
database := setupTestDB(t)
186+
ctx := context.Background()
187+
188+
cur := "1.0.0"
189+
p1 := &Package{
190+
Type: "plugin",
191+
Name: "plugin-one",
192+
VersionsJSON: "{}",
193+
CurrentVersion: &cur,
194+
IsActive: true,
195+
WpComposerInstallsTotal: 100,
196+
WpComposerInstalls30d: 25,
197+
}
198+
p2 := &Package{
199+
Type: "plugin",
200+
Name: "plugin-two",
201+
VersionsJSON: "{}",
202+
CurrentVersion: &cur,
203+
IsActive: false,
204+
WpComposerInstallsTotal: 999,
205+
WpComposerInstalls30d: 999,
206+
}
207+
t1 := &Package{
208+
Type: "theme",
209+
Name: "theme-one",
210+
VersionsJSON: "{}",
211+
CurrentVersion: &cur,
212+
IsActive: true,
213+
WpComposerInstallsTotal: 50,
214+
WpComposerInstalls30d: 5,
215+
}
216+
217+
if err := UpsertPackage(ctx, database, p1); err != nil {
218+
t.Fatalf("upserting plugin-one: %v", err)
219+
}
220+
if err := UpsertPackage(ctx, database, p2); err != nil {
221+
t.Fatalf("upserting plugin-two: %v", err)
222+
}
223+
if err := UpsertPackage(ctx, database, t1); err != nil {
224+
t.Fatalf("upserting theme-one: %v", err)
225+
}
226+
227+
// Install counters are maintained by telemetry aggregation, not package upserts.
228+
if _, err := database.Exec(`UPDATE packages SET wp_composer_installs_total = 100, wp_composer_installs_30d = 25 WHERE name = 'plugin-one'`); err != nil {
229+
t.Fatalf("updating plugin-one counters: %v", err)
230+
}
231+
if _, err := database.Exec(`UPDATE packages SET wp_composer_installs_total = 999, wp_composer_installs_30d = 999 WHERE name = 'plugin-two'`); err != nil {
232+
t.Fatalf("updating plugin-two counters: %v", err)
233+
}
234+
if _, err := database.Exec(`UPDATE packages SET wp_composer_installs_total = 50, wp_composer_installs_30d = 5 WHERE name = 'theme-one'`); err != nil {
235+
t.Fatalf("updating theme-one counters: %v", err)
236+
}
237+
238+
if err := RefreshSiteStats(ctx, database); err != nil {
239+
t.Fatalf("refreshing site stats: %v", err)
240+
}
241+
242+
var activePlugins, activeThemes, pluginInstalls, themeInstalls, installs30d int
243+
err := database.QueryRow(`SELECT active_plugins, active_themes, plugin_installs, theme_installs, installs_30d FROM package_stats WHERE id = 1`).Scan(
244+
&activePlugins, &activeThemes, &pluginInstalls, &themeInstalls, &installs30d,
245+
)
246+
if err != nil {
247+
t.Fatalf("querying package_stats: %v", err)
248+
}
249+
250+
if activePlugins != 1 {
251+
t.Errorf("active_plugins = %d, want 1", activePlugins)
252+
}
253+
if activeThemes != 1 {
254+
t.Errorf("active_themes = %d, want 1", activeThemes)
255+
}
256+
if pluginInstalls != 100 {
257+
t.Errorf("plugin_installs = %d, want 100", pluginInstalls)
258+
}
259+
if themeInstalls != 50 {
260+
t.Errorf("theme_installs = %d, want 50", themeInstalls)
261+
}
262+
if installs30d != 30 {
263+
t.Errorf("installs_30d = %d, want 30", installs30d)
264+
}
265+
}
266+
175267
func TestGetPackagesNeedingUpdate(t *testing.T) {
176268
database := setupTestDB(t)
177269
ctx := context.Background()

internal/telemetry/aggregate.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"database/sql"
66
"fmt"
77
"time"
8+
9+
"github.com/roots/wp-composer/internal/packages"
810
)
911

1012
// AggregateInstalls recomputes wp_composer_installs_total, wp_composer_installs_30d,
@@ -68,6 +70,10 @@ func AggregateInstalls(ctx context.Context, db *sql.DB) (AggregateResult, error)
6870
}
6971
resetCount, _ := resetResult.RowsAffected()
7072

73+
if err := packages.RefreshSiteStats(ctx, db); err != nil {
74+
return AggregateResult{}, err
75+
}
76+
7177
return AggregateResult{
7278
PackagesUpdated: totalUpdated,
7379
PackagesReset: resetCount,

internal/telemetry/telemetry_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ func setupTestDB(t *testing.T) *sql.DB {
3636
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
3737
UNIQUE(type, name)
3838
);
39+
CREATE TABLE package_stats (
40+
id INTEGER PRIMARY KEY CHECK (id = 1),
41+
active_plugins INTEGER NOT NULL DEFAULT 0,
42+
active_themes INTEGER NOT NULL DEFAULT 0,
43+
plugin_installs INTEGER NOT NULL DEFAULT 0,
44+
theme_installs INTEGER NOT NULL DEFAULT 0,
45+
installs_30d INTEGER NOT NULL DEFAULT 0,
46+
updated_at TEXT NOT NULL DEFAULT ''
47+
);
48+
INSERT INTO package_stats (id) VALUES (1);
3949
CREATE TABLE install_events (
4050
id INTEGER PRIMARY KEY,
4151
package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- +goose Up
2+
CREATE TABLE package_stats (
3+
id INTEGER PRIMARY KEY CHECK (id = 1),
4+
active_plugins INTEGER NOT NULL DEFAULT 0,
5+
active_themes INTEGER NOT NULL DEFAULT 0,
6+
plugin_installs INTEGER NOT NULL DEFAULT 0,
7+
theme_installs INTEGER NOT NULL DEFAULT 0,
8+
installs_30d INTEGER NOT NULL DEFAULT 0,
9+
updated_at TEXT NOT NULL
10+
);
11+
12+
INSERT INTO package_stats (id, active_plugins, active_themes, plugin_installs, theme_installs, installs_30d, updated_at)
13+
SELECT 1,
14+
COALESCE(SUM(CASE WHEN type = 'plugin' THEN 1 ELSE 0 END), 0),
15+
COALESCE(SUM(CASE WHEN type = 'theme' THEN 1 ELSE 0 END), 0),
16+
COALESCE(SUM(CASE WHEN type = 'plugin' THEN wp_composer_installs_total ELSE 0 END), 0),
17+
COALESCE(SUM(CASE WHEN type = 'theme' THEN wp_composer_installs_total ELSE 0 END), 0),
18+
COALESCE(SUM(wp_composer_installs_30d), 0),
19+
datetime('now')
20+
FROM packages
21+
WHERE is_active = 1;
22+
23+
-- +goose Down
24+
DROP TABLE package_stats;

0 commit comments

Comments
 (0)