|
| 1 | +# Advanced Queries |
| 2 | + |
| 3 | +The `/v1/query` endpoints let you query annotations the way you would query a table: pick the columns you want, filter on any of them, and get a tab-separated file back. They are the right tool for assembling a dataset for analysis, and they can express filters that the [fast annotation endpoints](fetch_annotations.md) cannot. |
| 4 | + |
| 5 | +The typical workflow is two steps: |
| 6 | + |
| 7 | +1. `GET /v1/query/columns` — discover what you can select and filter on. |
| 8 | +2. `POST /v1/query/download` — submit a query and download the results as TSV. |
| 9 | + |
| 10 | +`POST /v1/query/count` is a useful third step between the two: it tells you how many rows a query will return before you commit to downloading them. |
| 11 | + |
| 12 | +None of these endpoints require authentication — they are read-only. |
| 13 | + |
| 14 | +> **There is also `/v1/query/run`.** It accepts the same request body and returns TSV in the response body instead of as a file attachment. It builds the entire result set in memory before responding, so it is slower and far more memory-hungry than `/v1/query/download`, which streams rows to a file as it reads them. Prefer `/v1/query/download`. |
| 15 | +
|
| 16 | +## Everything queries a single view |
| 17 | + |
| 18 | +These endpoints do not query the annotation tables directly. They query one flat database view — named `annotations` by default, configurable with `DATABASE_QUERY_VIEW` — that joins imaged moments, observations, associations, image references, ancillary data, and video reference info into a single wide, denormalized row set. |
| 19 | + |
| 20 | +Two consequences matter: |
| 21 | + |
| 22 | +- **One row is not one annotation.** Because the view is a join across several one-to-many relationships, an observation with three associations and two framegrabs appears as six rows. Use `distinct`, or select only the columns you actually need, to control this. |
| 23 | +- **The available columns depend on the deployment.** The view is created by a migration and can be customized per site, so the column list is not part of the API contract. Always ask `/v1/query/columns` rather than hard-coding column names. |
| 24 | + |
| 25 | +## Step 1: discover the columns |
| 26 | + |
| 27 | +```text |
| 28 | +GET http://myserver.org/anno/v1/query/columns |
| 29 | +``` |
| 30 | + |
| 31 | +The response is a JSON array of column descriptors, one per column in the view: |
| 32 | + |
| 33 | +```json |
| 34 | +[ |
| 35 | + { |
| 36 | + "columnName": "index_recorded_timestamp", |
| 37 | + "columnType": "timestamptz", |
| 38 | + "columnSize": 35, |
| 39 | + "columnLabel": "index_recorded_timestamp", |
| 40 | + "columnClassName": "java.sql.Timestamp" |
| 41 | + }, |
| 42 | + { |
| 43 | + "columnName": "concept", |
| 44 | + "columnType": "varchar", |
| 45 | + "columnSize": 256, |
| 46 | + "columnLabel": "concept", |
| 47 | + "columnClassName": "java.lang.String" |
| 48 | + }, |
| 49 | + { |
| 50 | + "columnName": "depth_meters", |
| 51 | + "columnType": "float4", |
| 52 | + "columnSize": 15, |
| 53 | + "columnLabel": "depth_meters", |
| 54 | + "columnClassName": "java.lang.Float" |
| 55 | + } |
| 56 | +] |
| 57 | +``` |
| 58 | + |
| 59 | +`columnName` is what you put in `select`, `where`, and `orderBy`. `columnClassName` is the useful field for building a request, because **which constraints are legal depends on the column's type** — see [Choosing the right constraint](#choosing-the-right-constraint). |
| 60 | + |
| 61 | +The default view provides these columns, grouped by where they come from: |
| 62 | + |
| 63 | +| Source | Columns | |
| 64 | +| --- | --- | |
| 65 | +| Imaged moment | `imaged_moment_uuid`, `index_recorded_timestamp`, `index_elapsed_time_millis`, `index_timecode` | |
| 66 | +| Observation | `observation_uuid`, `concept`, `observer`, `activity`, `observation_group`, `observation_timestamp`, `duration_millis` | |
| 67 | +| Association | `link_name`, `to_concept`, `link_value`, `association_mime_type`, `associations` | |
| 68 | +| Image reference | `image_reference_uuid`, `image_url`, `image_format`, `image_width`, `image_height`, `image_description` | |
| 69 | +| Ancillary data | `latitude`, `longitude`, `depth_meters`, `altitude`, `salinity`, `temperature_celsius`, `oxygen_ml_per_l`, `pressure_dbar`, `light_transmission`, `coordinate_reference_system`, `x`, `y`, `z`, `phi`, `theta`, `psi`, `xyz_position_units` | |
| 70 | +| Video reference info | `camera_platform`, `dive_number`, `chief_scientist` | |
| 71 | + |
| 72 | +The column list above is the PostgreSQL view. The SQL Server view is nearly identical but names the concatenated association column `association` rather than `associations`, and adds an `association_uuid` column — another reason to read the column list from the server instead of assuming it. |
| 73 | + |
| 74 | +Note that `video_reference_uuid` is *not* in the default view. Filter by `dive_number` or `camera_platform` instead, or use the [fast endpoints](fetch_annotations.md) when you already have a video reference UUID. |
| 75 | + |
| 76 | +## Step 2: build the request |
| 77 | + |
| 78 | +The request body is the same for `download`, `count`, and `run`. Field names are camelCase; column names are the snake_case names from `/v1/query/columns`. |
| 79 | + |
| 80 | +```json |
| 81 | +{ |
| 82 | + "select": ["concept", "index_recorded_timestamp", "depth_meters"], |
| 83 | + "distinct": false, |
| 84 | + "where": [ |
| 85 | + { "column": "concept", "equals": "Nanomia bijuga" }, |
| 86 | + { "column": "depth_meters", "minmax": [200, 800] } |
| 87 | + ], |
| 88 | + "orderBy": ["index_recorded_timestamp"], |
| 89 | + "limit": 5000, |
| 90 | + "offset": 0, |
| 91 | + "concurrentObservations": false, |
| 92 | + "relatedAssociations": false, |
| 93 | + "strict": true |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +| Field | Meaning | |
| 98 | +| --- | --- | |
| 99 | +| `select` | Columns to return. **Required** for `download` and `run`; ignored by `count`. | |
| 100 | +| `distinct` | Emit `SELECT DISTINCT`. Default `false`. | |
| 101 | +| `where` | Constraints, combined with `AND`. **Required** by `count`, and by `download`/`run` when you explicitly set `"strict": true`. | |
| 102 | +| `orderBy` | Columns to sort by, always ascending. Defaults to the first column in `select`. | |
| 103 | +| `limit` / `offset` | Page through results. | |
| 104 | +| `concurrentObservations` | Also return the other observations recorded at the same imaged moment. Default `false`. | |
| 105 | +| `relatedAssociations` | Also return the other associations belonging to matched observations. Default `false`. | |
| 106 | +| `strict` | Whether to return *only* the columns you selected. Default `true`. | |
| 107 | + |
| 108 | +All constraints in `where` are ANDed together. There is no `OR` and no nesting; for alternatives on a single column use `in`. |
| 109 | + |
| 110 | +### Choosing the right constraint |
| 111 | + |
| 112 | +Each object in `where` names a `column` plus **exactly one** constraint. If you supply more than one, only the first is used, in the order listed below — the rest are silently ignored. Split them into separate `where` entries instead. |
| 113 | + |
| 114 | +| Constraint | JSON type | SQL | Use on | |
| 115 | +| --- | --- | --- | --- | |
| 116 | +| `between` | array of 2 ISO-8601 instants | `col BETWEEN ? AND ?` | timestamp columns | |
| 117 | +| `contains` | string | `col LIKE '%value%'` | text columns | |
| 118 | +| `equals` | string | `col = ?` | text and UUID columns | |
| 119 | +| `in` | array of strings | `col IN (?, …)` | text and UUID columns | |
| 120 | +| `isnull` | boolean | `col IS NULL` / `IS NOT NULL` | any column | |
| 121 | +| `like` | string | `col LIKE ?` | text columns | |
| 122 | +| `notlike` | string | `col NOT LIKE ?` | text columns | |
| 123 | +| `max` | number | `col <= ?` | numeric columns | |
| 124 | +| `min` | number | `col >= ?` | numeric columns | |
| 125 | +| `minmax` | array of 2 numbers | `col BETWEEN ? AND ?` | numeric columns | |
| 126 | + |
| 127 | +Matching the constraint to the column type matters, because a mismatch reaches the database as a type error rather than an empty result: |
| 128 | + |
| 129 | +- **Numeric columns** (`columnClassName` of `java.lang.Float`, `java.lang.Double`, `java.lang.Integer`, `java.math.BigDecimal`) take `min`, `max`, and `minmax`. Using `equals` or `like` on one fails — the value is sent as a string, and the database rejects `integer = character varying`. |
| 130 | +- **Text columns** (`java.lang.String`) take `equals`, `in`, `like`, `notlike`, and `contains`. Using `min`/`max`/`minmax` on one fails the same way, in reverse. |
| 131 | +- **Timestamp columns** (`java.sql.Timestamp`) take `between`, with exactly two ISO-8601 values. `between` is timestamps only — use `minmax` for numeric ranges. |
| 132 | +- **UUID columns** (`java.util.UUID`) work with `equals` and `in`, passing the UUID as a string. |
| 133 | +- `isnull` works on any column. |
| 134 | + |
| 135 | +With `like` and `notlike` you supply the `%` wildcards yourself; `contains` wraps the value in `%` for you. |
| 136 | + |
| 137 | +### `strict`, and the two expansion flags |
| 138 | + |
| 139 | +`strict` controls whether the query returns exactly the columns you asked for: |
| 140 | + |
| 141 | +- `"strict": true` (the default) returns precisely your `select` list, ordered by `select`'s first column unless you set `orderBy`. |
| 142 | +- `"strict": false` prepends `observation_uuid` and `index_recorded_timestamp` to your `select` list and orders by those two. The extra columns let a client regroup flat rows back into annotations. |
| 143 | + |
| 144 | +Setting `concurrentObservations` or `relatedAssociations` to `true` **forces `strict` to `false`**, whatever you passed in, because those results are meaningless without the grouping columns. |
| 145 | + |
| 146 | +The two flags widen the result set rather than narrowing it. Your `where` clause becomes a subquery that selects matching rows, and then: |
| 147 | + |
| 148 | +- `concurrentObservations` returns every row whose `imaged_moment_uuid` matched — all observations made at the same instant in the video, not just the ones matching your filter. |
| 149 | +- `relatedAssociations` returns every row whose `observation_uuid` matched — all associations on the matched observations. |
| 150 | +- Both together return all observations at the matched moments, along with all of their associations. |
| 151 | + |
| 152 | +```json |
| 153 | +{ |
| 154 | + "select": ["concept", "link_name", "link_value", "index_recorded_timestamp"], |
| 155 | + "where": [{ "column": "concept", "equals": "Sebastes" }], |
| 156 | + "relatedAssociations": true |
| 157 | +} |
| 158 | +``` |
| 159 | + |
| 160 | +That query returns every association on every *Sebastes* observation, not only the rows where the association itself matched. |
| 161 | + |
| 162 | +> **Note:** `distinct` is rarely useful with either flag set, since the forced `observation_uuid` column makes nearly every row unique. |
| 163 | +
|
| 164 | +## Step 3: check the size, then download |
| 165 | + |
| 166 | +`count` runs your `where` clause and returns just the row count. It requires a `where` clause and ignores `select`: |
| 167 | + |
| 168 | +```text |
| 169 | +POST http://myserver.org/anno/v1/query/count |
| 170 | +Content-Type: application/json |
| 171 | +
|
| 172 | +{ |
| 173 | + "where": [ |
| 174 | + { "column": "concept", "equals": "Nanomia bijuga" }, |
| 175 | + { "column": "depth_meters", "minmax": [200, 800] } |
| 176 | + ] |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +```json |
| 181 | +{ "count": 14203 } |
| 182 | +``` |
| 183 | + |
| 184 | +Then download the rows: |
| 185 | + |
| 186 | +```bash |
| 187 | +curl -X POST 'http://myserver.org/anno/v1/query/download' \ |
| 188 | + -H 'Content-Type: application/json' \ |
| 189 | + -o nanomia.tsv \ |
| 190 | + -d '{ |
| 191 | + "select": ["concept", "index_recorded_timestamp", "depth_meters", "latitude", "longitude"], |
| 192 | + "where": [ |
| 193 | + { "column": "concept", "equals": "Nanomia bijuga" }, |
| 194 | + { "column": "depth_meters", "minmax": [200, 800] } |
| 195 | + ], |
| 196 | + "orderBy": ["index_recorded_timestamp"] |
| 197 | + }' |
| 198 | +``` |
| 199 | + |
| 200 | +The response is a file attachment with `Content-Type: text/tab-separated-values` and `Content-Disposition: attachment; filename=results.tsv`. Give `curl` an `-o` of your own, as above, or use `-OJ` to accept the server's name — every download is named `results.tsv`, so `-OJ` will collide if you run more than one. |
| 201 | + |
| 202 | +Notes on the output: |
| 203 | + |
| 204 | +- The first line is a header of column names. |
| 205 | +- Rows are ordered by `orderBy`, or by the defaults described under [`strict`](#strict-and-the-two-expansion-flags). |
| 206 | +- `NULL` values are written as empty fields. |
| 207 | +- Timestamps are rendered in UTC. |
| 208 | +- Fields are **not** quoted or escaped. A `link_value` containing a tab or newline — free-text comments are the usual suspect — will break column alignment for that row. Exclude such columns, or post-process, when that matters. |
| 209 | +- The server writes the file to temporary storage and deletes it about two minutes after responding. Read the response body to completion; there is no URL to come back to later. |
| 210 | + |
| 211 | +## Worked examples |
| 212 | + |
| 213 | +**All annotations for one dive, grouped back into annotations by the client** |
| 214 | + |
| 215 | +```json |
| 216 | +{ |
| 217 | + "select": ["concept", "observer", "index_recorded_timestamp", "associations", "image_url"], |
| 218 | + "where": [{ "column": "dive_number", "equals": "Doc Ricketts 1373" }], |
| 219 | + "strict": false |
| 220 | +} |
| 221 | +``` |
| 222 | + |
| 223 | +**Concept counts by platform over a date range** — download and aggregate locally |
| 224 | + |
| 225 | +```json |
| 226 | +{ |
| 227 | + "select": ["camera_platform", "concept"], |
| 228 | + "where": [ |
| 229 | + { "column": "index_recorded_timestamp", "between": ["2023-01-01T00:00:00Z", "2024-01-01T00:00:00Z"] }, |
| 230 | + { "column": "concept", "isnull": false } |
| 231 | + ], |
| 232 | + "orderBy": ["camera_platform", "concept"] |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +**Distinct concepts observed below 1000 m** |
| 237 | + |
| 238 | +```json |
| 239 | +{ |
| 240 | + "select": ["concept"], |
| 241 | + "distinct": true, |
| 242 | + "where": [{ "column": "depth_meters", "min": 1000 }] |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +**Everything observed at the same moment as a squid** — the flag turns a filter for squid into a filter for moments containing squid |
| 247 | + |
| 248 | +```json |
| 249 | +{ |
| 250 | + "select": ["concept", "index_recorded_timestamp", "depth_meters"], |
| 251 | + "where": [{ "column": "concept", "contains": "squid" }], |
| 252 | + "concurrentObservations": true |
| 253 | +} |
| 254 | +``` |
| 255 | + |
| 256 | +**Measurement associations only, paged** |
| 257 | + |
| 258 | +```json |
| 259 | +{ |
| 260 | + "select": ["concept", "link_name", "to_concept", "link_value"], |
| 261 | + "where": [{ "column": "link_name", "like": "%length%" }], |
| 262 | + "orderBy": ["concept"], |
| 263 | + "limit": 1000, |
| 264 | + "offset": 0 |
| 265 | +} |
| 266 | +``` |
| 267 | + |
| 268 | +## Troubleshooting |
| 269 | + |
| 270 | +| Symptom | Likely cause | |
| 271 | +| --- | --- | |
| 272 | +| `select clause is required` | `download` and `run` need a non-empty `select`. | |
| 273 | +| `where clause is required` | `count` always needs `where`; so do `download`/`run` when you pass `"strict": true`. | |
| 274 | +| `operator does not exist: … = character varying` | A string constraint (`equals`, `in`, `like`, `contains`, `notlike`) on a numeric column. Use `min`/`max`/`minmax`. | |
| 275 | +| `operator does not exist: character varying >= double precision` | A numeric constraint on a text column. Use `equals`/`in`/`like`. | |
| 276 | +| A syntax error mentioning `ASC` | `orderBy` values are plain column names. `ASC` is appended for you, so `"concept DESC"` produces invalid SQL — descending order is not supported. | |
| 277 | +| Server error on a `between` or `minmax` | Both need exactly two array elements. | |
| 278 | +| Far more rows than expected | The view is a join; one observation can produce many rows. Narrow `select`, or set `distinct`. | |
| 279 | +| A column you expected is missing | The view is deployment-specific. Confirm with `/v1/query/columns`. | |
0 commit comments