4.5 SQL Operators, Aggregate & String Functions: Reviewing Retrieved Data Sets
Key Takeaways
- SQL statements divide into DML (SELECT, INSERT, UPDATE, DELETE), DDL (CREATE, ALTER, DROP, TRUNCATE) and DCL (GRANT, REVOKE); an audit extraction account should hold SELECT and nothing else.
- AND binds more tightly than OR, so a mixed condition written without parentheses returns a different population than intended - the single most common cause of a wrong audit population.
- Any comparison to NULL evaluates to UNKNOWN rather than TRUE, so a filter such as approver_id <> 'JSMITH' silently drops every unapproved row, which is usually the exact population under investigation.
- COUNT(*) counts all rows while COUNT(column) counts only non-NULL values, and AVG excludes NULL rows from its denominator, so an average computed over a partially null column overstates the true average across the full population.
- String functions matter because values that look identical on screen fail to join: UPPER and TRIM normalization should be applied before concluding that two systems share no matching records.
SQL Operators, Aggregate & String Functions
Quick Answer: The ISC blueprint task reads: "Examine a standard SQL query (common commands, clauses, operators, aggregate functions and string functions) to determine whether the retrieved data set is relevant and complete." You are not asked to write production SQL. You are asked to read someone else's query and decide whether the rows it returns are the rows the audit objective requires — no more and no fewer.
1. Common Commands
SQL statements fall into three families. Confusing them is a control issue, not just a vocabulary issue, because an extraction account should be able to execute exactly one family.
| Family | Statements | Purpose | Who Should Hold It |
|---|---|---|---|
| DML — Data Manipulation | SELECT, INSERT, UPDATE, DELETE | Read and change the data in tables | Applications; auditors get SELECT only |
| DDL — Data Definition | CREATE, ALTER, DROP, TRUNCATE | Change the structure of the database | Database administrators, under change management |
| DCL — Data Control | GRANT, REVOKE | Change who can do what | Security administration, segregated from DBAs where practical |
Exam trap: TRUNCATE is DDL, not DML. It removes all rows without logging individual deletions and generally cannot be rolled back the way a DELETE can. A finding that an application service account holds TRUNCATE rights on a financial table is severe.
2. Operators and the Three-Valued Logic Problem
Comparison and Range
=, <> (or !=), <, >, <=, >=, BETWEEN x AND y (inclusive of both endpoints), IN (list), NOT IN (list).
Pattern Matching
LIKE with % matching any sequence of characters and _ matching exactly one. So LIKE 'INV%' finds every invoice number beginning INV; LIKE '%2026%' finds it anywhere in the string.
Logical Operators and Precedence
AND binds more tightly than OR. This single fact is responsible for more wrong audit populations than any other SQL feature:
-- WRONG: returns every 2026 disbursement of any amount,
-- plus every wire of any date, because AND evaluates first.
WHERE payment_type = 'WIRE'
OR payment_type = 'ACH'
AND payment_date >= '2026-01-01'
-- RIGHT: parentheses force the intended grouping.
WHERE (payment_type = 'WIRE' OR payment_type = 'ACH')
AND payment_date >= '2026-01-01'
NULL: The Completeness Killer
SQL uses three-valued logic — TRUE, FALSE, and UNKNOWN. Any comparison to NULL returns UNKNOWN, and a WHERE clause keeps only rows that evaluate to TRUE. Four consequences a reviewer must check:
WHERE approver_id <> 'JSMITH'silently drops every row where approver_id is NULL. If the audit objective is "everything not approved by Smith," the unapproved items — the highest-risk population — are exactly the ones excluded. UseWHERE approver_id <> 'JSMITH' OR approver_id IS NULL.NOT INwith a subquery containing a single NULL returns no rows at all. PreferNOT EXISTSor add anIS NOT NULLfilter inside the subquery.= NULLnever matches anything. The only valid tests areIS NULLandIS NOT NULL.- NULL is not zero and not an empty string. A NULL discount and a 0.00 discount behave differently in both filtering and averaging.
Date Boundaries
WHERE invoice_date <= '2026-12-31' excludes everything stamped 2026-12-31 at 09:14 if the column is a timestamp rather than a date, because the literal is interpreted as midnight. The safe construction is a half-open range: >= '2026-01-01' AND < '2027-01-01'.
3. Aggregate Functions
| Function | Behavior With NULLs | Audit Use |
|---|---|---|
COUNT(*) | Counts every row, NULLs included | The population count to reconcile against a system control report |
COUNT(column) | Counts rows where that column is not NULL | Quantifies missing values: COUNT(*) - COUNT(approval_date) is the number of unapproved items |
COUNT(DISTINCT column) | Counts distinct non-NULL values | Number of distinct vendors paid; detecting a vendor master that has more IDs than legal entities |
SUM(column) | Ignores NULLs; returns NULL if every row is NULL | The control total to tie to the general ledger |
AVG(column) | Ignores NULLs in the denominator as well | Dangerous: the average of 100 rows where 40 are NULL is computed over 60, not 100 |
MIN / MAX | Ignore NULLs | Date range of the population; largest single transaction for scoping |
The AVG trap, concretely: ten invoices, six carrying a discount of 10 and four carrying NULL.
AVG(discount)returns 10, not 6. If the audit question is "what is the average discount granted across all invoices," the query must convert NULL to zero explicitly (AVG(COALESCE(discount, 0))).
Remember the clause rule from the query-processing order: aggregate conditions belong in HAVING, never in WHERE, because WHERE is evaluated before groups exist.
4. String Functions
String functions appear in ISC questions for one reason: data that looks identical on screen does not match on a join.
| Function (typical form) | What It Does | Why an Auditor Uses It |
|---|---|---|
UPPER(s) / LOWER(s) | Forces case | Matching vendor names across systems where one stores uppercase |
TRIM(s) / LTRIM / RTRIM | Removes leading and trailing spaces | Fixed-width legacy extracts pad every field; the padding breaks equality joins invisibly |
LENGTH(s) / LEN(s) | Character count | Validating that every taxpayer identification number has nine digits, or finding truncated fields |
SUBSTRING(s, start, count) | Extracts part of a string | Pulling a cost center out of an embedded account code |
LEFT(s, n) / RIGHT(s, n) | Leading or trailing characters | Grouping general ledger accounts by their first four digits |
CONCAT(a, b) or a || b | Joins strings | Building a composite match key from vendor ID plus invoice number |
REPLACE(s, old, new) | Substitutes text | Stripping punctuation from names or hyphens from identifiers before matching |
POSITION / CHARINDEX | Locates a substring | Finding where a delimiter falls before splitting a field |
The Canonical Failure
Two systems both show vendor "ACME CORP". One stores ACME CORP and the other stores acme corp with a trailing space. a.vendor_name = b.vendor_name returns zero matches, and the analyst concludes there are no shared vendors. Normalizing both sides — UPPER(TRIM(a.vendor_name)) = UPPER(TRIM(b.vendor_name)) — is what makes the comparison meaningful. A zero-match join result should always be treated as a suspected normalization failure before it is treated as a finding.
5. Reviewing a Query for Relevance and Completeness
This is the actual blueprint task. Work the checklist in order.
Completeness — could a row that belongs in the population have been excluded?
- Table and join. Does the FROM clause start from the driving table for the objective? An INNER JOIN silently drops unmatched rows — which are frequently the exceptions being hunted. Testing for shipments without invoices requires a LEFT JOIN with an IS NULL filter, not an INNER JOIN.
- NULL handling. Does any filter use
<>,NOT IN, or a comparison operator on a nullable column without an explicit NULL branch? - Date boundaries. Half-open range, or an inclusive endpoint on a timestamp column that drops the last day?
- Operator precedence. Are there mixed AND/OR conditions without parentheses?
- Row limits. Is there a LIMIT, TOP or FETCH FIRST clause left over from testing?
- Status filters. Does a
status = 'POSTED'condition exclude voided, reversed or in-flight items that the objective requires?
Relevance — does the result contain rows that do not belong, or omit needed columns?
- Scope filters. Are entity, ledger, currency and period filters present and correct for the objective?
- Grain. Does the join change the grain and inflate aggregates? Check any SUM taken across a one-to-many join.
- Duplicates. Is DISTINCT being used to mask a join defect rather than to state a real requirement?
- Columns. Does the SELECT list carry the fields needed to evidence the conclusion — identifiers, dates, amounts, approver — or only a total that cannot be traced?
Then reconcile. A reviewed query is not a verified population. The final step is always to tie COUNT(*) and SUM(amount) to an independently generated system control report.
An auditor reviews a query intended to extract every 2026 journal entry that was not approved by the controller. The WHERE clause reads: approver_id <> 'CONTROLLER' AND entry_date >= '2026-01-01'. What is the defect?
A table contains 500 purchase orders. The discount_pct column is populated on 200 of them and NULL on the other 300. An analyst runs SELECT AVG(discount_pct) FROM purchase_orders and reports the result as the average discount across all purchase orders. What is wrong with that conclusion?
An analyst joins a vendor list extracted from the procurement system to a vendor list extracted from the accounts payable system on vendor_name and obtains zero matching rows. Both systems are known to serve hundreds of the same suppliers. What should the analyst do before reporting that the two vendor populations are unrelated?