Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ All basic commands are managed by our Makefile:

`make watch` - Build and run the app with Air for live reloading during development (automatically rebuilds and restarts on code changes).

### FTS5 (SQLite) builds and tests

The server uses `github.com/mattn/go-sqlite3` and SQLite FTS5 for route search. Build and test with the FTS5 tag enabled:

```bash
CGO_ENABLED=1 go test -tags "sqlite_fts5" ./...
# or
CGO_ENABLED=1 go build -tags "sqlite_fts5" ./...
```

Ensure you have a working C toolchain when CGO is enabled.

## Directory Structure

* `bin` contains compiled application binaries, ready for deployment to a production server.
Expand Down
1 change: 1 addition & 0 deletions cmd/api/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ func TestBuildApplicationWithConfigFile(t *testing.T) {
// Convert to absolute path to avoid path traversal validation issues
absTestDataPath, err := filepath.Abs(testDataPath)
require.NoError(t, err)
absTestDataPath = filepath.ToSlash(absTestDataPath)

// Create a test config file that uses the test data
testConfigPath := filepath.Join("..", "..", "testdata", "config_test_build.json")
Expand Down
10 changes: 10 additions & 0 deletions gtfsdb/db.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions gtfsdb/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,31 @@ ORDER BY
agency_id,
id;

-- name: SearchRoutesByFullText :many
SELECT
r.id,
r.agency_id,
r.short_name,
r.long_name,
r."desc",
r.type,
r.url,
r.color,
r.text_color,
r.continuous_pickup,
r.continuous_drop_off
FROM
routes_fts
JOIN routes r ON r.rowid = routes_fts.rowid
WHERE
routes_fts MATCH @query
ORDER BY
bm25(routes_fts),
r.agency_id,
r.id
LIMIT
@limit;

-- name: GetRouteIDsForAgency :many
SELECT
r.id
Expand Down
66 changes: 66 additions & 0 deletions gtfsdb/query.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions gtfsdb/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,78 @@ CREATE TABLE
FOREIGN KEY (agency_id) REFERENCES agencies (id)
);

-- migrate
CREATE VIRTUAL TABLE IF NOT EXISTS routes_fts USING fts5 (
id UNINDEXED,
agency_id UNINDEXED,
short_name,
long_name,
desc,
content = 'routes',
content_rowid = 'rowid'
);

-- migrate
CREATE TRIGGER IF NOT EXISTS routes_fts_ai AFTER INSERT ON routes BEGIN
INSERT INTO
routes_fts(rowid, id, agency_id, short_name, long_name, desc)
VALUES
(
new.rowid,
new.id,
new.agency_id,
coalesce(new.short_name, ''),
coalesce(new.long_name, ''),
coalesce(new.desc, '')
);
END;

-- migrate
CREATE TRIGGER IF NOT EXISTS routes_fts_ad AFTER DELETE ON routes BEGIN
INSERT INTO
routes_fts(routes_fts, rowid, id, agency_id, short_name, long_name, desc)
VALUES
(
'delete',
old.rowid,
old.id,
old.agency_id,
coalesce(old.short_name, ''),
coalesce(old.long_name, ''),
coalesce(old.desc, '')
);
END;

-- migrate
CREATE TRIGGER IF NOT EXISTS routes_fts_au AFTER UPDATE ON routes BEGIN
INSERT INTO
routes_fts(routes_fts, rowid, id, agency_id, short_name, long_name, desc)
VALUES
(
'delete',
old.rowid,
old.id,
old.agency_id,
coalesce(old.short_name, ''),
coalesce(old.long_name, ''),
coalesce(old.desc, '')
);
INSERT INTO
routes_fts(rowid, id, agency_id, short_name, long_name, desc)
VALUES
(
new.rowid,
new.id,
new.agency_id,
coalesce(new.short_name, ''),
coalesce(new.long_name, ''),
coalesce(new.desc, '')
);
END;

-- migrate
INSERT INTO routes_fts(routes_fts) VALUES ('rebuild');

-- migrate
CREATE TABLE
IF NOT EXISTS stops (
Expand Down
46 changes: 46 additions & 0 deletions internal/gtfs/route_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package gtfs

import (
"context"
"strings"

"maglev.onebusaway.org/gtfsdb"
)

// buildRouteSearchQuery normalizes user input into an FTS5-safe prefix search query.
func buildRouteSearchQuery(input string) string {
terms := strings.Fields(strings.ToLower(input))
safeTerms := make([]string, 0, len(terms))

for _, term := range terms {
trimmed := strings.TrimSpace(term)
if trimmed == "" {
continue
}
escaped := strings.ReplaceAll(trimmed, `"`, `""`)
safeTerms = append(safeTerms, `"`+escaped+`"*`)
}

if len(safeTerms) == 0 {
return ""
}

return strings.Join(safeTerms, " AND ")
}

// SearchRoutes performs a full text search against routes using SQLite FTS5.
func (manager *Manager) SearchRoutes(ctx context.Context, input string, maxCount int) ([]gtfsdb.Route, error) {
limit := maxCount
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}

query := buildRouteSearchQuery(input)
return manager.GtfsDB.Queries.SearchRoutesByFullText(ctx, gtfsdb.SearchRoutesByFullTextParams{
Query: query,
Limit: int64(limit),
})
}
94 changes: 94 additions & 0 deletions internal/restapi/route_search_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package restapi

import (
"net/http"
"strings"

"maglev.onebusaway.org/internal/models"
"maglev.onebusaway.org/internal/utils"
)

func (api *RestAPI) routeSearchHandler(w http.ResponseWriter, r *http.Request) {
queryParams := r.URL.Query()

input := queryParams.Get("input")
sanitizedInput, err := utils.ValidateAndSanitizeQuery(input)
if err != nil {
fieldErrors := map[string][]string{
"input": {err.Error()},
}
api.validationErrorResponse(w, r, fieldErrors)
return
}

if strings.TrimSpace(sanitizedInput) == "" {
fieldErrors := map[string][]string{
"input": {"input is required"},
}
api.validationErrorResponse(w, r, fieldErrors)
return
}

maxCount := 20
var fieldErrors map[string][]string
if maxCountStr := queryParams.Get("maxCount"); maxCountStr != "" {
parsedMaxCount, fe := utils.ParseFloatParam(queryParams, "maxCount", fieldErrors)
fieldErrors = fe
if parsedMaxCount <= 0 {
fieldErrors["maxCount"] = append(fieldErrors["maxCount"], "must be greater than zero")
} else {
maxCount = int(parsedMaxCount)
if maxCount > 100 {
fieldErrors["maxCount"] = append(fieldErrors["maxCount"], "must not exceed 100")
}
}
}

if len(fieldErrors) > 0 {
api.validationErrorResponse(w, r, fieldErrors)
return
}

ctx := r.Context()
if ctx.Err() != nil {
api.serverErrorResponse(w, r, ctx.Err())
return
}

routes, err := api.GtfsManager.SearchRoutes(ctx, sanitizedInput, maxCount)
if err != nil {
api.serverErrorResponse(w, r, err)
return
}

results := make([]models.Route, 0, len(routes))
agencyIDs := make(map[string]bool)
for _, routeRow := range routes {
agencyIDs[routeRow.AgencyID] = true
results = append(results, models.NewRoute(
utils.FormCombinedID(routeRow.AgencyID, routeRow.ID),
routeRow.AgencyID,
routeRow.ShortName.String,
routeRow.LongName.String,
routeRow.Desc.String,
models.RouteType(routeRow.Type),
routeRow.Url.String,
routeRow.Color.String,
routeRow.TextColor.String,
routeRow.ShortName.String,
))
}

agencies := utils.FilterAgencies(api.GtfsManager.GetAgencies(), agencyIDs)
references := models.ReferencesModel{
Agencies: agencies,
Routes: []interface{}{},
Situations: []interface{}{},
StopTimes: []interface{}{},
Stops: []models.Stop{},
Trips: []interface{}{},
}

response := models.NewListResponse(results, references)
api.sendResponse(w, r, response)
}
Loading
Loading