-
-
Notifications
You must be signed in to change notification settings - Fork 144
Enhancement: Implement server-side filters for records API #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- Update query generation based on list items, enabling multiple optional query statements which are `AND`'ed together - Parse `running` parameter as `None | bool` - Apply query for running or stopped records, when `running` is `True` or `False` respectively - Add documentation for new query parameter - Add tests for new query parameter
- Parse `hidden` parameter as `None | bool` using the same pattern as `running` - Apply query for hidden or non-hidden records, when `hidden` is `True` or `False` respectively - Hidden records are identified by descriptions starting with "HIDDEN" - Add documentation for new query parameter - Add comprehensive tests for new query parameter, including combination with `running` filter
- Parse `tag` parameter to extract comma-separated list of tags - Ignore client-provided hashtag prefix for flexibility - Escape special SQL LIKE characters to prevent injection - Apply query to match records containing all specified tags - Tags are matched as `#tagname` followed by space or end of description - Add documentation for new query parameter - Add comprehensive tests for single and multiple tag filtering, including URL-encoded hashtag prefix handling
| query_parts.append( | ||
| "json_extract(_ob, '$.ds') LIKE ? ESCAPE '\\' OR json_extract(_ob, '$.ds') LIKE ? ESCAPE '\\'" | ||
| ) | ||
| safe_params += [f"%#{tag} %", f"%#{tag}"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this duplicates the exact same query-part.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey Almar, appreciate the review!
This is deliberate and actually not a duplicate. But the difference is subtle - the difference lies in the safe_params, which render slightly differently.
Here's an example: searching for tag "foo" will render this query part:
json_extract(_ob, '$.ds') LIKE '℅#foo %' ESCAPE '\' OR json_extract(_ob, '$.ds') LIKE '%#foo' ESCAPE '\'That first bit matches tags at the beginning or somewhere in the middle of the description. The exact match here is:
LIKE '℅#foo %'
℅arbitrary-length wildcard#literal hashtagfooliteral tag nanewhitespace%arbitrary-length wildcard
The second bit matches tags at the end of the description, i.e. followed explicitly by the end of the string. It is matching exactly:
LIKE '℅#foo'
℅arbitrary-length wildcard#literal hashtagfooliteral tag nane- end of string
The reason why I am not using a simpler query here is because I want to prevent partial matches. For example, this simplified query:
json_extract(_ob, '$.ds') LIKE '℅#foo%' ESCAPE '\'...would also match #fooBar, which is not the intended behavior.
This would be more straightforward, if sqlite supported regular expressions. - then we could simply regex-search for .*#foo(\s.*|$) - But without regex, the solution above is the only way to make this work effectively.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the explanation. I indeed overlooked that subtle difference :)
Summary
This PR adds three new optional query parameters to the
GET /api/v2/recordsendpoint to enable efficient server-side filtering:running: Filter records by their running state (running vs. stopped)hidden: Filter records by their hidden state (deleted records)tag: Filter records by tags in their description (supports single and multiple tags)Motivation
This enhancement is driven by two key needs:
1. External Tooling Integration
Third-party CLI tools (including projects like
better-timetagger-cliand other custom integrations) need efficient ways to query specific subsets of records - ideally without having to load large data sets that require a lot of local filtering.2. Performance at Scale
Server-side SQL-based filtering is more efficient than client-side filtering, especially when dealing with larger datasets spanning months or years of time tracking data. By filtering records on the server before transmission, we:
Implementation Details
Query Parameter Specifications
running- Filter by Running Statetrue,yes,on,1,y): returns only running records (wheret1 == t2)false,no,off,0,n): returns only stopped recordshidden- Filter by Hidden Statetag- Filter by Tags?tag=work?tag=work,urgent#prefix (%23) or plain tag namesFilter Behavior
All filters:
timerangeparameterChanges Made
1. API Implementation (
timetagger/server/_apiserver.py)get_records()handler:runningparameter asNone(omitted) orbool.hiddenparameter asNone(omitted) orbool.tagas comma-separated list of sanitized tag namesget_records()handler:runningfilter: selects only records wheret1matchest2or nothiddenfilter: selects only records where_ob.dsstarts with "HIDDEN" or nottagfilter: selects only records where all tags are present in_ob.ds, either within the description (followed by whitespace) or at the end of the description (at end of string)FALSY_VALUESconstant for consistent boolean parsing across the API codebaseFALSY_VALUESconstant to other API handlers (get_webtoken,get_apitoken) for code consistency and reduced duplication2. Documentation (
docs/docs/webapi.md)GET /recordsendpoint signature to include new query parameters3. Test Suite (
tests/test_server_apiserver.py)test_records_get_running_filter(): Tests running/stopped record filteringtest_records_get_hidden_filter(): Tests hidden/visible record filteringtest_records_get_tag_filter(): Tests single and multiple tag filteringtest_records_get_combined_filters(): Tests using multiple filter parameters togethertimerangeparameterAll tests pass successfully ✅
Backward Compatibility
These changes are fully backward compatible. All new query parameters are optional, and the API behaves identically to the previous version when they are not specified.
Example Usage
Filter for running records
Filter for non-hidden records with specific tags
Combine all filters