Suite Utils

SuiteQL checker

Every rule the su-ns-harness SuiteQL checker applies before a query reaches NetSuite, and how to override a false positive with [nolint].

On this page

Before a SuiteQL call goes out, the PreToolUse hook checks the query. A deny blocks the call, and Claude gets the reason plus a fix, often a rewritten query. A warn lets the call through and puts the warning in Claude's context.

You can run the same checks yourself without sending anything:

nsx sql lint "SELECT entity, SUM(foreigntotal) FROM transaction WHERE ROWNUM <= 100 GROUP BY entity"

That example exits with status 1. It has the ROWNUM bug described below, and the output includes the rewritten query.

Each table below names the rule id. That's the name nsx sql lint prints and the one you pass to [nolint:<rule>].

Row caps and paging

ROWNUM caps rows before GROUP BY and ORDER BY run. You get wrong totals and a wrong top-N with no error. Moving ROWNUM to an outer query doesn't help, because NetSuite still applies the outer ROWNUM before the inner GROUP BY. The fix is ORDER BY … FETCH FIRST n ROWS ONLY at the end of the query.

RuleIdAction
ROWNUM at the same level as GROUP BY or aggregatesrownum-with-aggregateDeny, even with [nolint]. Rewritten with FETCH FIRST
ROWNUM in a subquery that feeds an aggregaterownum-before-aggregateDeny
ROWNUM at the same level as ORDER BY (wrong top-N)rownum-with-order-byDeny. Rewritten with FETCH FIRST
ROWNUM over an aggregated or sorted subquery, such as SELECT * FROM (<GROUP BY … ORDER BY …>) WHERE ROWNUM <= N. NetSuite applies the outer ROWNUM firstrownum-over-subqueryDeny, even with [nolint]. Rewritten with FETCH FIRST
A ROWNUM condition that can never match a row: ROWNUM > n (n ≥ 1), ROWNUM = n, >= n or BETWEEN n AND … with n > 1, or ROWNUM <> 1rownum-greater-thanDeny, even with [nolint]
LIMIT n, which SuiteQL doesn't havelimit-clauseDeny. Rewritten as FETCH FIRST n ROWS ONLY
LIMIT n OFFSET mlimit-clauseDeny, even with [nolint], because the OFFSET rule below fires too. Page with pageSize + pageIndex and a unique ORDER BY
OFFSET n ROWS with n > 0, for example OFFSET 100 ROWS FETCH NEXT 100 ROWS ONLY. NetSuite ignores the OFFSET and returns the first rows againoffset-ignoredDeny, even with [nolint]. When the query ends in OFFSET n ROWS FETCH NEXT m ROWS ONLY, m is at least 5 and n is a multiple of m, the suggested fix drops both clauses and calls with pageSize: m, pageIndex: n/m. Otherwise page with pageSize + pageIndex, or WHERE t.id > <last id>
pageIndex above 0 on a detail query without ORDER BY. Pages can overlap or skip rows(none)Warn

Query shape

RuleIdAction
Anything but a SELECT query (DELETE, UPDATE, DROP…). The connector only runs read-only queries anyway, so this rule exists to fail fast with a message that names the statementnot-selectDeny, even with [nolint]
More than one statement (SELECT …; SELECT …). A single trailing ; is finemulti-statementDeny, even with [nolint]
HTML-escaped operators outside string literals (&lt;=, &gt;, &amp;, &#60;)html-entityDeny, even with [nolint], naming the entity
An incomplete query: SELECT with no column list or no FROM <table>. Usually an unquoted query cut short on the command lineincomplete-queryDeny
x.col where x isn't a table or alias in FROM or JOIN, in this query or an enclosing oneundefined-aliasDeny
SELECT * on a table. Probes with FETCH FIRST 1 or ROWNUM <= 1 are fine, and so is SELECT * over a subqueryselect-starDeny
WITH and CTEs. Oracle's docs say "You can't use WITH clauses"cte-unsupportedDeny. Rewrite as a FROM subquery
[bracketed] identifiersbracket-identifierDeny
More than 1000 items in one IN (…)in-list-too-longDeny

Syntax SuiteQL doesn't support

RuleIdAction
Functions SuiteQL doesn't support: LEFT, RIGHT, SUBSTRING, CHARINDEX, CEILING, LISTAGG, DATEDIFF, …unsupported-functionDeny, with the supported alternative (SUBSTR, INSTR, CEIL, …)
DATE '…' and TIMESTAMP '…' literalsdate-literalDeny. Use TO_DATE(…)
+ next to a text literal, or next to a column the cached metadata types as string (a.acctnumber + a.acctname)string-plus-concatDeny. Concatenate with the double-pipe operator instead
(+) outer joins mixed with ANSI JOIN … ON in one query blockmixed-join-syntaxDeny
(+) on the left of = (Oracle right outer join)right-outer-plusDeny. Swap the operands
A double-quoted value compared to a column. SuiteQL reads "CustInvc" as a column namedouble-quoted-stringWarn. Use single quotes
ORDER BY on the alias of an aggregate or expression in a GROUP BY query, such as ORDER BY n DESC for COUNT(*) AS n. NetSuite can reject it with "Invalid or unsupported search"order-by-alias-group-byWarn. Order by the full expression (COUNT(*) DESC)
A date column compared to a plain string (trandate >= '2026-01-01')string-date-compareWarn. Use TO_DATE(…)

The double-pipe operator is ||, as in a.acctnumber || ' ' || a.acctname.

Values and tables

RuleIdAction
type = 'invoice' or recordtype = 'CustInvc': codes and record ids swappedtype-literal, recordtype-literalDeny, with the right value
A type or recordtype value the plugin doesn't know. Its lists aren't completetype-literal-unknown, recordtype-unknownWarn. The value may still be right. Check it if the query returns nothing
approvalstatus compared to a value, for a type the account doesn't use approvals for. The filter would return zero rowsapprovalstatus-no-workflowDeny
status = 'A' without BUILTIN.CF. Raw status holds only the letterstatus-without-cfWarn. Use BUILTIN.CF(t.status) = 'CustInvc:A'
A table missing from the cached record-type list, meaning the connector role can't see it(none)Deny before the call. Plain [nolint] overrides it, in case the list is out of date
A column missing from the connector's field metadata for a cached tableunknown-column, unknown-custom-fieldWarn, with the closest name (amout → amount)

The unknown-column rule only warns because the connector's field metadata is incomplete. transactionline.amount, for one, works but isn't listed. A bad column fails with a generic "unexpected SuiteScript error" that doesn't name the field, so the warning is what tells Claude which column to suspect.

Totals that add the wrong things

RuleIdAction
Summing transactionline amounts without a mainline or line-type filtertransactionline-sum-unfilteredWarn
SUM(foreigntotal) or SUM(foreignamount) without currency in the GROUP BY or a filter to one currency. This adds currencies togethersum-mixed-currencyWarn. Suggests a base amount per subsidiary, the consolidated report or BUILTIN.CONSOLIDATE, or BUILTIN.CURRENCY_CONVERT
SUM of transactionaccountingline amount, debit, credit or netamount without grouping or filtering by subsidiary, on an account whose subsidiaries use different currenciessum-across-subsidiariesWarn. Group or filter by tl.subsidiary, or use the consolidated report
Budgets and transaction tables joined and aggregated in one query (budget-vs-actual fan-out)budget-fanoutWarn
A date or posting filter in LEFT JOIN … ON while aggregating a left-table column. The left-side sums inflateleft-join-aggregateWarn
A date or posting filter on the left table inside LEFT JOIN … ON. It removes no rowsleft-join-on-filterWarn. Move it to WHERE

The currency SUM rules skip counts like SUM(CASE WHEN … THEN 1 ELSE 0 END) or SUM(DECODE(…, 1, 0)).

Overriding a false positive

Add a marker to the query's description. That's the connector's description field, not the SQL:

  • [nolint:<rule>] overrides one rule, for example [nolint:select-star]. Separate several rules with commas.
  • [nolint] overrides every rule that can be overridden.

Plain [nolint] is also how Claude queries a table the cached record-type list doesn't have yet. That check has no rule id, so [nolint:<rule>] can't target it.

Overridden errors are listed in Claude's context, so Claude checks the result against them.

What can't be overridden

Nothing overrides the rules marked "even with [nolint]" above, because their results would always be wrong:

  • ROWNUM with GROUP BY or aggregates
  • ROWNUM over a sorted or aggregated subquery
  • ROWNUM conditions that never match a row
  • OFFSET
  • anything but a SELECT
  • a second statement
  • HTML-escaped operators

[nolint] also doesn't skip the first-use metadata step. The first query on a table whose fields aren't cached still triggers one ns_getSuiteQLMetadata call first, so the columns get checked instead of guessed.

nsx doctor counts the guard's denials per rule over the last 7 days with activity. A rule that shows up often may be a false positive worth reporting.

Install su-ns-harness

Run both inside Claude Code, then /reload-plugins. Version 0.5.1, free and open source.

  1. /plugin marketplace add suiteutils/suiteutils-netsuite-claude-harness
  2. /plugin install su-ns-harness@suiteutils

Page last updated 2026-09-28. NetSuite is a trademark of Oracle. Claude is a trademark of Anthropic. Suite Utils is not affiliated with either.