From ed0600cf6a7168ebb81fb692df26f9dafeb8b729 Mon Sep 17 00:00:00 2001 From: Matt Rollender Date: Wed, 3 Jun 2026 14:24:53 -0400 Subject: [PATCH] Add crm-query and crm-reports skills for CLI-friendly CRM data querying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crm-query: translates the Breeze crm-query skill into hubspot CLI equivalents — objects search, properties list, pipelines, associations, and owners commands. Covers schema discovery, record filtering, counting, and owner lookup. crm-reports: wraps hubspot reports create "" for server-side aggregations, GROUP BY, time series, and cross-object filters in a single call. The two skills are designed to work together: crm-query for simple filter/list/count, crm-reports for anything requiring server-side computation. Gaps with no CLI path (web analytics, marketing email metrics, LLM content analysis) are surfaced explicitly in crm-query. Co-Authored-By: Claude Sonnet 4.6 --- crm-query/SKILL.md | 210 ++++++++++++++++++++ crm-query/resources/aggregation-patterns.md | 151 ++++++++++++++ crm-query/resources/filter-operators.md | 117 +++++++++++ crm-reports/SKILL.md | 100 ++++++++++ crm-reports/resources/sql-syntax.md | 112 +++++++++++ 5 files changed, 690 insertions(+) create mode 100644 crm-query/SKILL.md create mode 100644 crm-query/resources/aggregation-patterns.md create mode 100644 crm-query/resources/filter-operators.md create mode 100644 crm-reports/SKILL.md create mode 100644 crm-reports/resources/sql-syntax.md diff --git a/crm-query/SKILL.md b/crm-query/SKILL.md new file mode 100644 index 0000000..a5440b0 --- /dev/null +++ b/crm-query/SKILL.md @@ -0,0 +1,210 @@ +--- +name: crm-query +description: Filter, list, and count HubSpot CRM records from the terminal, and discover portal schema (object types, properties, pipelines, owners). Use when the user asks to filter records by criteria, count records, look up what object types or properties exist, or resolve owner names to IDs. For aggregations (GROUP BY, SUM, AVG), time series, or cross-object filters in a single call, use crm-reports. For basic lookup by ID/email/domain/name, use crm-lookup. For deal pipeline snapshots and win/loss analysis, use sales-reporting. Website traffic analytics, marketing email metrics, and LLM-based content analysis have no CLI equivalent. +triggers: + - "filter contacts" + - "filter deals" + - "filter records" + - "count contacts" + - "count deals" + - "how many contacts" + - "how many deals" + - "how many records" + - "what object types" + - "list all properties" + - "object schema" + - "what properties exist" + - "find owner" + - "list owners" + - "resolve owner" + - "contacts with no deals" + - "companies with no activity" +--- + +## Foundations + +Read [`bulk-operations/SKILL.md`](../bulk-operations/SKILL.md) first — JSONL piping, batch-read, pagination, and the safety flow for downstream writes live there. `hubspot --help` is authoritative; trust it over this file if they conflict. + +For basic lookup by ID, email, domain, or name fragment, see [`crm-lookup/SKILL.md`](../crm-lookup/SKILL.md). For aggregations (GROUP BY, SUM, AVG, time series, cross-object filters), see [`crm-reports/SKILL.md`](../crm-reports/SKILL.md). For deal pipeline snapshots and win/loss analysis, see [`sales-reporting/SKILL.md`](../sales-reporting/SKILL.md). + +## Resources + +| File | When to use | +|---|---| +| `resources/filter-operators.md` | Full filter syntax: operators, AND/OR rules, date windows, null/token patterns. | +| `resources/aggregation-patterns.md` | jq recipes for counting, summing, averaging, and time-series grouping. | + +## What the CLI cannot do + +Tell the user upfront if their request falls into one of these unsupported areas: + +- **Aggregations, GROUP BY, time series, cross-object filters** — use `crm-reports` instead (`hubspot reports create ""`). +- **Website traffic analytics** (`web_analytics.*`) — no CLI equivalent; direct user to HubSpot Reports in the UI. +- **Marketing email metrics** (`EXT_EMAIL_*`, campaign sends/opens/clicks) — no CLI equivalent; direct user to HubSpot Email Analytics in the UI. +- **LLM analysis of call/email/deal content** — no CLI equivalent; available in HubSpot Breeze AI. + +## 1. Discover CRM schema + +Properties, pipeline stages, and custom object types are **portal-specific** — always discover at runtime rather than hardcoding. + +```bash +# all object types in this portal (standard + custom) +hubspot objects types + +# all properties for an object type +hubspot properties list --type contacts +hubspot properties list --type deals +hubspot properties list --type companies + +# search for properties by name fragment (pipe to grep) +hubspot properties list --type contacts | grep -i "lifecycle" +hubspot properties list --type deals | grep -i "close" + +# details for a specific property (label, type, options/enum values) +hubspot properties get --type contacts lifecyclestage + +# association types available from an object +hubspot associations types --from-type contacts +hubspot associations types --from-type deals + +# deal pipelines and their stages (IDs are portal-specific) +hubspot pipelines list --type deals --format jsonl +hubspot pipelines stages --type deals --pipeline default --format jsonl + +# grab a stage ID by its label +QUALIFIED=$(hubspot pipelines stages --type deals --pipeline default --format jsonl \ + | jq -r 'select(.label=="Qualified To Buy") | .id') +``` + +## 2. Search and filter records + +`search` returns ≤ 100 records per call. Paginate (see bulk-operations) before counting or aggregating — a result of exactly 100 is almost always truncated. + +```bash +# exact match on enum / string +hubspot objects search --type contacts \ + --filter "lifecyclestage=marketingqualifiedlead" \ + --properties email,firstname,lastname,hubspot_owner_id + +# multiple AND conditions in one --filter +hubspot objects search --type deals \ + --filter "hs_is_closed!=true AND dealstage=qualifiedtobuy" \ + --properties dealname,amount,dealstage,closedate + +# OR conditions — multiple --filter flags +hubspot objects search --type contacts \ + --filter "lifecyclestage=lead" \ + --filter "lifecyclestage=marketingqualifiedlead" \ + --properties email,firstname,lifecyclestage + +# date range — ISO dates (YYYY-MM-DD) work in comparisons +TODAY=$(date +%Y-%m-%d) +LAST_30=$(date -v-30d +%Y-%m-%d 2>/dev/null || date -d '30 days ago' +%Y-%m-%d) +hubspot objects search --type contacts \ + --filter "createdate>=$LAST_30 AND createdate<=$TODAY" \ + --properties email,firstname,createdate + +# null / missing property +hubspot objects search --type contacts --filter "!email" --properties firstname,lastname +hubspot objects search --type contacts --filter "email" --properties firstname,lastname,email + +# token match (whole words only — "acme" matches "Acme Corp" but not "AcmeTech") +hubspot objects search --type deals --filter "dealname~acme" --properties dealname,dealstage +``` + +See `resources/filter-operators.md` for the full operator list and more examples. + +## 3. Count records + +```bash +# quick count (≤100 — if the number is exactly 100, paginate for the real count) +hubspot objects search --type contacts --filter "lifecyclestage=lead" --properties email \ + | jq -s 'length' + +# accurate count for any number of records — paginate all, count lines +bash bulk-operations/resources/pagination-loop.sh contacts /tmp/leads.jsonl email \ + '--filter' 'lifecyclestage=lead' +wc -l < /tmp/leads.jsonl +``` + +Never report a count of 100 without first paginating — `search` silently truncates. + +## 4. Aggregate, group, and cross-object queries + +For aggregations (COUNT, SUM, AVG, GROUP BY, time series) and cross-object filters, use `crm-reports`: + +```bash +# count by dimension — use crm-reports +hubspot reports create \ + "SELECT dealstage, COUNT(*), SUM(amount_in_home_currency) FROM DEAL GROUP BY dealstage" \ + --intent "Deals by stage" + +# cross-object filter — use crm-reports +hubspot reports create \ + "SELECT dealname, amount_in_home_currency FROM DEAL WHERE COMPANY.industry = 'RETAIL'" \ + --intent "Deals at retail companies" +``` + +See [`crm-reports/SKILL.md`](../crm-reports/SKILL.md) for the full command reference and SQL syntax. + +**jq fallback for simple grouping** (when you already have records paginated locally): + +All properties come back as **strings** — use `tonumber` for arithmetic. See `resources/aggregation-patterns.md` for the full cookbook. + +```bash +jq -rs ' + group_by(.properties.dealstage) + | map({stage: .[0].properties.dealstage, count: length}) + | sort_by(-.count)[] + | "\(.stage)\t\(.count)"' /tmp/deals.jsonl \ +| column -t -s$'\t' +``` + +## 5. Records with no association + +`crm-reports` handles most cross-object queries, but checking for the **absence** of an association requires a client-side pass: + +```bash +# deals with no associated contact +bash bulk-operations/resources/pagination-loop.sh deals /tmp/deals.jsonl dealname,dealstage +jq -r '.id' /tmp/deals.jsonl \ +| while IFS= read -r id; do + count=$(hubspot associations list --from deals:$id --to contacts 2>/dev/null | wc -l | tr -d ' ') + [ "$count" -eq 0 ] && jq -c "select(.id == \"$id\")" /tmp/deals.jsonl + done +``` + +> For large datasets (>500 records), this loop spawns one process per record and will be slow. Tell the user and paginate a filtered subset first to reduce the count. + +## 6. Owner lookup and resolution + +```bash +# dump owners once — reuse across queries +hubspot owners list --format jsonl > /tmp/owners.jsonl + +# find owner ID by email (for use in --filter) +jq -r 'select(.email == "john@acme.com") | .id' /tmp/owners.jsonl + +# find owner ID by name fragment +jq -r 'select((.firstName + " " + .lastName) | ascii_downcase | contains("john smith")) | .id' \ + /tmp/owners.jsonl + +# resolve owner IDs to names in a result set +hubspot objects search --type deals --filter "hs_is_closed!=true" \ + --properties dealname,amount,hubspot_owner_id \ +| jq -c --slurpfile owners /tmp/owners.jsonl ' + . + {owner_name: ( + ($owners[0][] | select(.id == .properties.hubspot_owner_id) + | .firstName + " " + .lastName) // "unknown" + )}' +``` + +## Known constraints + +- `search` returns ≤ 100 records per page; paginate before counting or aggregating. +- Multiple `--filter` flags are OR'd; use `AND` inside a single flag for AND conditions. +- `~` is token/word-boundary matching only — use `jq | ascii_downcase | contains(...)` for substring. +- Date properties accept ISO format (`YYYY-MM-DD`) in `--filter` comparisons. +- `hubspot owners list` returns CRM users only; there is no `teams` object — group by `hubspot_owner_id` client-side. +- Aggregations and cross-object filters: use `crm-reports` (see "What the CLI cannot do" above). +- Website analytics, marketing email metrics, and LLM content analysis have no CLI equivalent. diff --git a/crm-query/resources/aggregation-patterns.md b/crm-query/resources/aggregation-patterns.md new file mode 100644 index 0000000..c1d85b0 --- /dev/null +++ b/crm-query/resources/aggregation-patterns.md @@ -0,0 +1,151 @@ +# Aggregation Patterns (jq) + +Run these after collecting all matching records into a JSONL file with the pagination loop. A bare `search` caps at 100 rows — always paginate before aggregating. + +```bash +# general form +bash bulk-operations/resources/pagination-loop.sh /tmp/out.jsonl [extra_flags...] +``` + +All property values in JSONL output are **strings**. Use `tonumber` for arithmetic and `// 0` / `// null` to handle nulls. + +--- + +## Count by dimension + +```bash +bash bulk-operations/resources/pagination-loop.sh deals /tmp/deals.jsonl dealstage + +jq -rs ' + group_by(.properties.dealstage) + | map({stage: .[0].properties.dealstage, count: length}) + | sort_by(-.count)[] + | "\(.stage)\t\(.count)"' /tmp/deals.jsonl \ +| column -t -s$'\t' +``` + +## Count + sum by dimension + +```bash +bash bulk-operations/resources/pagination-loop.sh deals /tmp/deals.jsonl dealstage,amount \ + '--filter' 'hs_is_closed!=true' + +jq -rs ' + group_by(.properties.dealstage) + | map({ + stage: .[0].properties.dealstage, + count: length, + total: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round) + }) + | sort_by(-.total)[] + | "\(.stage)\tcount:\(.count)\tvalue:$\(.total)"' /tmp/deals.jsonl \ +| column -t -s$'\t' +``` + +## Average by dimension + +```bash +jq -rs ' + group_by(.properties.dealtype) + | map( + . as $group | + { + type: ($group[0].properties.dealtype // "unknown"), + count: ($group | length), + avg: ( + [ $group[].properties.amount | select(. != null) | tonumber ] + | if length > 0 then (add / length | round) else 0 end + ) + } + ) + | .[] + | "\(.type)\tcount:\(.count)\tavg:$\(.avg)"' /tmp/deals.jsonl \ +| column -t -s$'\t' +``` + +## Min / max + +```bash +jq -rs ' + map(select(.properties.amount != null)) + | { + max: (max_by(.properties.amount | tonumber) + | {name: .properties.dealname, amount: (.properties.amount | tonumber)}), + min: (min_by(.properties.amount | tonumber) + | {name: .properties.dealname, amount: (.properties.amount | tonumber)}) + }' /tmp/deals.jsonl +``` + +## Time series — group by month + +```bash +bash bulk-operations/resources/pagination-loop.sh deals /tmp/won.jsonl amount,closedate \ + '--filter' 'hs_is_closed_won=true' + +jq -rs ' + group_by(.properties.closedate[0:7]) + | map({ + month: .[0].properties.closedate[0:7], + count: length, + revenue: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round) + }) + | sort_by(.month)[] + | "\(.month)\tdeals:\(.count)\trevenue:$\(.revenue)"' /tmp/won.jsonl \ +| column -t -s$'\t' +``` + +## Multi-dimension grouping + +```bash +bash bulk-operations/resources/pagination-loop.sh deals /tmp/deals.jsonl \ + dealstage,hubspot_owner_id,amount '--filter' 'hs_is_closed!=true' + +jq -rs ' + group_by([.properties.dealstage, .properties.hubspot_owner_id]) + | map({ + stage: .[0].properties.dealstage, + owner: .[0].properties.hubspot_owner_id, + count: length, + total: ([.[].properties.amount | select(. != null) | tonumber] | add // 0 | round) + }) + | sort_by([.stage, -.total])[] + | "\(.stage)\towner:\(.owner)\tcount:\(.count)\tvalue:$\(.total)"' \ +| column -t -s$'\t' +``` + +## Records with / without a property set + +```bash +jq -rs ' + { + with_phone: (map(select(.properties.phone != null and .properties.phone != "")) | length), + without_phone: (map(select(.properties.phone == null or .properties.phone == "")) | length) + }' /tmp/contacts.jsonl +``` + +## Resolve owner IDs to names + +Dump owners once, then join by ID: + +```bash +hubspot owners list --format jsonl > /tmp/owners.jsonl + +# join when reading back results (two-file jq input) +jq -rs ' + (.[1:][0] | map({(.id): (.firstName + " " + .lastName)}) | add) as $names | + .[0][] | + . + {owner_name: ($names[.properties.hubspot_owner_id] // "unknown")} +' /tmp/deals.jsonl /tmp/owners.jsonl +``` + +Or inline with `--slurpfile` when piping: + +```bash +hubspot objects search --type deals --filter "hs_is_closed!=true" \ + --properties dealname,hubspot_owner_id \ +| jq -c --slurpfile owners /tmp/owners.jsonl ' + . + {owner_name: ( + ($owners[0][] | select(.id == .properties.hubspot_owner_id) + | .firstName + " " + .lastName) // "unknown" + )}' +``` diff --git a/crm-query/resources/filter-operators.md b/crm-query/resources/filter-operators.md new file mode 100644 index 0000000..5e94f7d --- /dev/null +++ b/crm-query/resources/filter-operators.md @@ -0,0 +1,117 @@ +# Filter Operators Reference + +Use `--filter "expression"` with `hubspot objects search`. + +**AND / OR rules:** +- Multiple `--filter` flags are **OR'd**. +- Multiple conditions in a single `--filter "A AND B"` are **AND'd**. + +```bash +# AND — one --filter flag with AND +hubspot objects search --type deals \ + --filter "hs_is_closed!=true AND hubspot_owner_id=12345" + +# OR — multiple --filter flags +hubspot objects search --type contacts \ + --filter "lifecyclestage=lead" \ + --filter "lifecyclestage=marketingqualifiedlead" + +# AND + OR combined: (closed=false AND owner=X) OR (closed=false AND owner=Y) +# — not directly expressible; use a jq post-filter on a broader search instead +``` + +## Operators + +| Operator | Syntax | Notes | +|---|---|---| +| Equals | `field=value` | String, enum, boolean, numeric, owner ID | +| Not equals | `field!=value` | Excludes exact matches | +| Greater than | `field>value` | Numeric or date (`YYYY-MM-DD`) | +| Greater than or equal | `field>=value` | | +| Less than | `field=$LAST_30" \ + --properties email,firstname,lastname,createdate + +# deals closing in the next 7 days +hubspot objects search --type deals \ + --filter "closedate>=$TODAY AND closedate<=$NEXT_7 AND hs_is_closed!=true" \ + --properties dealname,closedate,amount + +# deals with no activity for 30+ days +hubspot objects search --type deals \ + --filter "hs_last_activity_date<$LAST_30 AND hs_is_closed!=true" \ + --properties dealname,dealstage,hs_last_activity_date + +# explicit date range (e.g. Q1 2026) +hubspot objects search --type deals \ + --filter "closedate>=2026-01-01 AND closedate<2026-04-01 AND hs_is_closed_won=true" \ + --properties dealname,amount,closedate +``` + +## Null / missing property + +```bash +# contacts missing an email +hubspot objects search --type contacts --filter "!email" --properties firstname,lastname + +# contacts that have an email set +hubspot objects search --type contacts --filter "email" --properties firstname,lastname,email + +# deals missing a close date (and still open) +hubspot objects search --type deals --filter "!closedate AND hs_is_closed!=true" \ + --properties dealname,dealstage +``` + +## Boolean and enum fields + +HubSpot stores booleans as strings; the filter API parses them: + +```bash +# open deals +hubspot objects search --type deals --filter "hs_is_closed!=true" + +# closed-won deals only +hubspot objects search --type deals --filter "hs_is_closed_won=true" + +# closed-lost deals +hubspot objects search --type deals --filter "hs_is_closed=true AND hs_is_closed_won!=true" +``` + +## Token match vs substring + +`~` matches whole words/tokens. For substring matching, use a `jq` post-filter: + +```bash +# "acme" matches "Acme Corp" and "ACME" — but NOT "AcmeTech" +hubspot objects search --type deals --filter "dealname~acme" --properties dealname,dealstage + +# substring match: post-filter in jq +hubspot objects search --type deals --filter "dealname~acme" --properties dealname,dealstage \ +| jq -c 'select(.properties.dealname | ascii_downcase | contains("acmetech"))' +``` diff --git a/crm-reports/SKILL.md b/crm-reports/SKILL.md new file mode 100644 index 0000000..6deeada --- /dev/null +++ b/crm-reports/SKILL.md @@ -0,0 +1,100 @@ +--- +name: crm-reports +description: Run server-side SQL reports against HubSpot CRM data from the terminal. Use when the user needs aggregations, cross-object queries, time series, or GROUP BY analysis. Prefer this over crm-query when the request involves counting/summing across a dimension, time bucketing, or cross-object filters in a single call. +triggers: + - "reports create" + - "create report" + - "sql report" + - "run a report" + - "group by stage" + - "group by owner" + - "count by" + - "sum by" + - "revenue by month" + - "deals by stage" + - "contacts by lifecycle" + - "time series" + - "cross-object filter" + - "deals at retail companies" +--- + +## Overview + +`hubspot reports create` executes a SQL query server-side against HubSpot CRM data and streams back the result set. It supports aggregations, GROUP BY, DATE_TRUNC time bucketing, and cross-object filters — capabilities that require multi-step workarounds with `hubspot objects search`. + +For full SQL syntax, see [`resources/sql-syntax.md`](resources/sql-syntax.md). `hubspot reports create --help` is authoritative on flags. + +## Command + +```bash +hubspot reports create "" --intent "" +``` + +- `sql` (required, positional): A valid HubSpot CRM SQL query. +- `--intent` (required): A short human-readable label for what the query does (e.g. "Deals by stage"). Used for display and logging — does not affect execution. +- `--format` (optional): `jsonl` (default), `json`, or `table`. + +## When to use this vs `crm-query` + +| Scenario | Use | +|---|---| +| Aggregate / GROUP BY (count, sum, avg by a dimension) | `crm-reports` | +| Time series (revenue by month, contacts by week) | `crm-reports` | +| Cross-object filter in a single call | `crm-reports` | +| Simple filter + list of records | `crm-query` | +| Basic lookup by ID, email, name | `crm-lookup` | +| Pipeline snapshots, win/loss | `sales-reporting` | + +## Examples + +**Deals by stage (count + value):** +```bash +hubspot reports create \ + "SELECT dealstage, COUNT(*), SUM(amount_in_home_currency) FROM DEAL GROUP BY dealstage" \ + --intent "Deals by stage" +``` + +**Contacts created this month:** +```bash +hubspot reports create \ + "SELECT COUNT(*) FROM CONTACT WHERE CURRENT_PERIOD(createdate, 'MONTH')" \ + --intent "Contacts created this month" +``` + +**Revenue by close month (won deals, last 6 months):** +```bash +hubspot reports create \ + "SELECT DATE_TRUNC(closedate, 'MONTH'), SUM(amount_in_home_currency) FROM DEAL WHERE hs_is_closed_won = 'true' AND PREVIOUS_PERIOD(closedate, 'MONTH', 6, false) GROUP BY DATE_TRUNC(closedate, 'MONTH')" \ + --intent "Revenue by close month" +``` + +**Deals at retail companies (cross-object filter):** +```bash +hubspot reports create \ + "SELECT dealname, amount_in_home_currency, dealstage FROM DEAL WHERE COMPANY.industry = 'RETAIL'" \ + --intent "Deals at retail companies" +``` + +**Open deals by owner:** +```bash +hubspot reports create \ + "SELECT hubspot_owner_id, COUNT(*), SUM(amount_in_home_currency) FROM DEAL WHERE hs_is_closed != 'true' GROUP BY hubspot_owner_id" \ + --intent "Open deals by owner" +``` + +## Key rules + +- Always discover portal-specific values (stage IDs, owner IDs, enum values) with `crm-query` before writing WHERE conditions that reference them. +- `amount_in_home_currency` is the normalised deal value field; prefer it over `amount` for aggregations. +- Stage IDs are portal-specific strings — get them with `hubspot pipelines stages --type deals` before using in WHERE. +- Owner IDs are numeric strings — resolve names with `hubspot owners list` after the query returns. +- `hs_is_closed_won` and `hs_is_closed` filter values must be quoted strings: `= 'true'`, `!= 'true'`. +- Max 2 different associated object types per query when using cross-object filters. + +## Known limitations + +- `SELECT DISTINCT`, `COUNT(DISTINCT x)`, `JOIN`, `UNION`, subqueries, CTEs, and `HAVING` are not supported. +- `AS` aliases are not supported — column names in the result match the property name. +- `LIKE`/`ILIKE` is not supported — use `KEYWORD_SEARCH_QUERY('term', 'property')` for text search. +- `CASE WHEN`, `IF()`, string functions (`CONCAT`, `UPPER`) are not supported. +- Website traffic analytics (`web_analytics.*`) and marketing email metrics (`EXT_EMAIL_*`) are not available via this command. diff --git a/crm-reports/resources/sql-syntax.md b/crm-reports/resources/sql-syntax.md new file mode 100644 index 0000000..47197bc --- /dev/null +++ b/crm-reports/resources/sql-syntax.md @@ -0,0 +1,112 @@ +# HubSpot CRM SQL Syntax Reference + +This is the SQL dialect accepted by `hubspot reports create`. It queries HubSpot CRM data directly server-side. + +## Basic query shape + +```sql +SELECT property1, property2, ... +FROM OBJECT_TYPE +[WHERE condition] +[GROUP BY dimension] +[ORDER BY expression [ASC|DESC]] +[LIMIT n] +[OFFSET n] +``` + +`FROM` targets: `CONTACT`, `DEAL`, `COMPANY`, `TICKET`, `QUOTE`, custom object API names (e.g. `p1234567_my_object`), and event types (`e_*`, `pe_*`). + +`SELECT *` is supported but returns all properties — prefer explicit lists for large objects. + +## WHERE operators + +| Operator | Applies to | Example | +|---|---|---| +| `=`, `!=` | all types | `dealstage = 'closedwon'` | +| `<`, `<=`, `>`, `>=` | numbers, dates | `amount > 10000` | +| `IN (...)` | strings, enums | `dealstage IN ('closedwon', 'closedlost')` | +| `NOT IN (...)` | strings, enums | `lifecyclestage NOT IN ('subscriber')` | +| `BETWEEN x AND y` | numbers, dates | `createdate BETWEEN '2025-01-01' AND '2025-12-31'` | +| `IS NULL` / `IS NOT NULL` | all | `closedate IS NOT NULL` | +| `AND`, `OR` | logical | `amount > 1000 AND hs_is_closed != 'true'` | +| `KEYWORD_SEARCH_QUERY('term', 'prop')` | text | `KEYWORD_SEARCH_QUERY('enterprise', 'dealname')` | + +Note: boolean property values are strings in HubSpot — use `= 'true'`, `!= 'true'`. + +## Aggregation functions + +`COUNT(*)`, `SUM(property)`, `AVG(property)`, `MIN(property)`, `MAX(property)`, `MEDIAN(property)` + +Aggregations require a `GROUP BY` clause (or `SELECT COUNT(*)` with no `GROUP BY` for a total). + +```sql +SELECT dealstage, COUNT(*), SUM(amount_in_home_currency) +FROM DEAL +GROUP BY dealstage +ORDER BY SUM(amount_in_home_currency) DESC +``` + +## GROUP BY + +Group by one or more dimensions: + +```sql +GROUP BY dealstage, hubspot_owner_id +``` + +Time bucketing with `DATE_TRUNC`: + +```sql +GROUP BY DATE_TRUNC(createdate, 'MONTH') +``` + +Intervals: `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` + +## Date period functions + +Use in WHERE to express relative time windows (only one per query): + +| Function | Meaning | +|---|---| +| `CURRENT_PERIOD(prop, 'UNIT')` | Current calendar unit (e.g. this month) | +| `CURRENT_PERIOD_SO_FAR(prop, 'UNIT')` | Current unit up to now | +| `PREVIOUS_PERIOD(prop, 'UNIT', count, isFiscal)` | Last N units | +| `NEXT_PERIOD(prop, 'UNIT', count, isFiscal)` | Next N units | + +Units: `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` + +```sql +-- This month +WHERE CURRENT_PERIOD(createdate, 'MONTH') + +-- Last 3 months +WHERE PREVIOUS_PERIOD(closedate, 'MONTH', 3, false) +``` + +## Cross-object filters + +Filter the primary object by a property on an associated object (max 2 associated types per query): + +```sql +-- Deals at retail companies +WHERE COMPANY.industry = 'RETAIL' + +-- Contacts with at least one associated deal +WHERE associations.DEAL IS NOT NULL +``` + +## Pagination + +Use `LIMIT` and `OFFSET` for large result sets. The response includes a `hasMore` field when more pages exist. + +## Unsupported syntax (will fail) + +- `SELECT DISTINCT` or `COUNT(DISTINCT x)` +- `JOIN`, `UNION`, subqueries, CTEs (`WITH ...`) +- `AS` column aliases +- `LIKE` / `ILIKE` (use `KEYWORD_SEARCH_QUERY` instead) +- `HAVING` +- `CASE WHEN`, `IF()`, `IIF()` +- String functions: `CONCAT`, `UPPER`, `RIGHT`, etc. +- Date functions: `QUARTER()`, `YEAR()`, `MONTH()` — use `DATE_TRUNC` instead +- List filters cannot be combined with aggregations