{
  "info": {
    "_postman_id": "a8f4e2c1-0d3b-4f1a-9c8e-7b6a5d4e3f21",
    "name": "FileMaker 22 OData Field Guide",
    "description": "Companion Postman collection for the 4-part FileMaker 22 OData blog series.\n\nEvery example query from the series, organized by post, with expected result counts where the seed data has been verified.\n\n## Setup\n\nThis collection uses 4 variables:\n- `base_url` \u2014 your server's OData root, e.g. `https://my-server.example.com/fmi/odata/v4`\n- `filename` \u2014 the FileMaker file name (without `.fmp12`), default `OData_Demo`\n- `username` \u2014 FileMaker account with the `fmodata` extended privilege\n- `password` \u2014 the account's password\n\nSet these in the collection variables panel or in an environment file. The collection uses Basic Auth at the collection level \u2014 every request inherits.\n\n## Verifying your seed data\n\nMany requests have automated test scripts that check the response against the expected count from the seed data. Run the collection in Postman's Collection Runner to verify your import worked correctly. A green run means your seed data matches the published CSVs.\n\n## Folder structure\n\n- **Part 1 \u2014 Function Library** \u2014 the 23 verified-working OData functions\n- **Part 2 \u2014 Server-Side Aggregation** \u2014 `$apply`, `groupby`, multi-aggregate, `cast()` inside aggregate\n- **Part 3 \u2014 Joins and Computed Fields** \u2014 `$compute`, `$expand`, `$crossjoin`\n- **Part 4 \u2014 Silent Failures** \u2014 the queries that look right and return wrong answers",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "basic",
    "basic": [
      {
        "key": "username",
        "value": "{{username}}",
        "type": "string"
      },
      {
        "key": "password",
        "value": "{{password}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "base_url",
      "value": "https://your-server.example.com/fmi/odata/v4",
      "description": "OData root URL (no trailing slash, no filename). Example: https://fms.example.com/fmi/odata/v4"
    },
    {
      "key": "filename",
      "value": "OData_Demo",
      "description": "FileMaker file name without the .fmp12 extension"
    },
    {
      "key": "username",
      "value": "",
      "description": "FileMaker account with fmodata extended privilege"
    },
    {
      "key": "password",
      "value": "",
      "description": "Account password"
    }
  ],
  "item": [
    {
      "name": "Part 1 \u2014 Function Library",
      "description": "The 23 verified-working canonical OData functions on FileMaker 22.0.6, plus a couple that fail at runtime. Every example from Part 1 of the series.",
      "item": [
        {
          "name": "[strings] contains() \u2014 companies with 'Trading' in name",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=contains(CompanyName,'Trading')",
            "description": "From: Part 1 \u2014 String functions: contains, startswith, endswith\n\nDemonstrates: case-sensitive substring match using `contains()`. Returns customers whose company name contains the literal string 'Trading'."
          }
        },
        {
          "name": "[strings] startswith() \u2014 companies starting with 'North'",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=startswith(CompanyName,'North')",
            "description": "From: Part 1 \u2014 String functions: contains, startswith, endswith\n\nDemonstrates: prefix match using `startswith()`. Case-sensitive."
          }
        },
        {
          "name": "[strings] endswith() \u2014 emails at @tidewater.example",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=endswith(Email,'tidewater.example')",
            "description": "From: Part 1 \u2014 String functions: contains, startswith, endswith\n\nDemonstrates: suffix match using `endswith()`. Useful for 'find everyone at this domain' queries when you don't have a normalized company field.\n\nExpected: 31 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 31 customers at @tidewater.example', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(31);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[strings] tolower() \u2014 case-insensitive exact match",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=tolower(ContactName) eq 'victor saito'",
            "description": "From: Part 1 \u2014 String functions: tolower, toupper\n\nDemonstrates: case-insensitive equality by lowercasing both sides. Combine with contains/startswith/endswith for case-insensitive partial matches."
          }
        },
        {
          "name": "[strings] toupper() inside contains() \u2014 products with 'STOVE'",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=contains(toupper(ProductName),'STOVE')",
            "description": "From: Part 1 \u2014 String functions: tolower, toupper\n\nDemonstrates: case-insensitive substring match by uppercasing the field and matching against an uppercase literal."
          }
        },
        {
          "name": "[strings] length() \u2014 products with names longer than 18 chars",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=length(ProductName) gt 18",
            "description": "From: Part 1 \u2014 String functions: length\n\nDemonstrates: filtering on character count of a text field. Useful for data-quality checks (find empty or unusually long values).\n\nExpected: 51 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 51 products with name length > 18', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(51);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[strings] substring() \u2014 exact match on first 4 chars",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=substring(ContactName,0,4) eq 'Vict'",
            "description": "From: Part 1 \u2014 String functions: substring\n\nDemonstrates: extracting a portion of a string by position (zero-indexed, start and length). For prefix matches, prefer `startswith()` for readability."
          }
        },
        {
          "name": "[strings] concat() \u2014 composing a display field with $compute",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$compute=concat(concat(ContactName,' - '),CompanyName) as Display&$select=CustomerID,ContactName,CompanyName&$top=3",
            "description": "From: Part 1 \u2014 String functions: concat\n\nDemonstrates: concatenation. `concat()` is binary \u2014 for 3+ values, nest the calls. The `Display` alias rides along in the response automatically; do not put it in `$select`.\n\nExpected: 3 rows, each with a `Display` key."
          }
        },
        {
          "name": "[strings] trim() \u2014 exact match against trimmed value",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=trim(ContactName) eq 'Victor Saito'",
            "description": "From: Part 1 \u2014 String functions: trim\n\nDemonstrates: stripping leading and trailing whitespace before equality check. On clean seed data this is a no-op; on legacy data with stray whitespace it can be the difference between getting rows and getting zero."
          }
        },
        {
          "name": "[cast] cast(text \u2192 Decimal) for numeric filtering",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=cast(MonthlyCostText,Edm.Decimal) gt 5.00",
            "description": "From: Part 1 \u2014 Type coercion: cast\n\nDemonstrates: filtering on a text-stored numerical value. Without the cast, numeric comparisons against text fields fail with `8309 \"incompatible data types\"`."
          }
        },
        {
          "name": "[dates] now() \u2014 customers signed up before now",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=cast(SignupTimestamp,Edm.DateTimeOffset) le now()",
            "description": "From: Part 1 \u2014 Date and time: now\n\nDemonstrates: filtering against the server's current time using `now()`. Returns nearly all customers (most signed up before any given moment).\n\nNote: OData has no built-in date-arithmetic for 'subtract N days from now' \u2014 compute the cutoff client-side and pass it as a literal."
          }
        },
        {
          "name": "[dates] explicit cutoff literal \u2014 signups since April 2025",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=cast(SignupTimestamp,Edm.DateTimeOffset) ge 2025-04-30T00:00:00Z",
            "description": "From: Part 1 \u2014 Date and time: now\n\nDemonstrates: computing a cutoff client-side and passing it as a literal. The unquoted ISO 8601 form is what works \u2014 quoted forms fall back to string comparison."
          }
        },
        {
          "name": "[dates] year() \u2014 orders in 2025",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=year(OrderDate) eq 2025&$count=true&$top=1",
            "description": "From: Part 1 \u2014 Date and time: year, month, day\n\nDemonstrates: extracting the year from a Date field. Cleanest way to slice by year without a precomputed field.\n\nExpected: @count: 2753"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('@count is 2753 for orders in 2025', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(2753);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[dates] year() + month() \u2014 orders in November 2025",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=year(OrderDate) eq 2025 and month(OrderDate) eq 11&$count=true&$top=1",
            "description": "From: Part 1 \u2014 Date and time: year, month, day\n\nDemonstrates: combining year() and month() to slice to a specific month. Compose any number of component extractors with `and`.\n\nExpected: @count: 265"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('@count is 265 for orders in Nov 2025', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(265);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[dates] date() \u2014 extract date from Timestamp",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=date(SignupTimestamp) eq 2025-11-15",
            "description": "From: Part 1 \u2014 Date and time: date\n\nDemonstrates: extracting just the date portion from a Timestamp field. Useful for 'did this happen on this calendar day' queries that ignore the time portion."
          }
        },
        {
          "name": "[numerics] round() with cast \u2014 rounded MonthlyCost",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$compute=round(cast(MonthlyCostText,Edm.Decimal)) as Rounded&$select=ProductName,MonthlyCostText&$top=5",
            "description": "From: Part 1 \u2014 Numeric functions: round, floor, ceiling\n\nDemonstrates: wrapping a numeric function around a cast. round() uses banker's rounding on .5 boundaries \u2014 not arithmetic rounding. Use floor() to round down, ceiling() to round up."
          }
        },
        {
          "name": "[strings] indexof() in $compute \u2014 position of '@' in Email",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Customers?$filter=contains(Email,'@')&$compute=indexof(Email,'@') as AtPos&$select=ContactName,Email&$top=5",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Customers"
              ],
              "query": [
                {
                  "key": "$filter",
                  "value": "contains(Email,'@')"
                },
                {
                  "key": "$compute",
                  "value": "indexof(Email,'@') as AtPos"
                },
                {
                  "key": "$select",
                  "value": "ContactName,Email"
                },
                {
                  "key": "$top",
                  "value": "5"
                }
              ]
            },
            "description": "From: Part 1 \u2014 A function with context-specific behavior\n\nDemonstrates: `indexof` works inside `$compute` and returns the zero-indexed position of the substring. Note the value comes back as a JSON string (`\"AtPos\": \"6\"`), not a number \u2014 wrap with `cast()` if a downstream expression needs a numeric.\n\nExpected: 5 rows, each with `AtPos` set to the position of `@` in the email."
          },
          "response": []
        },
        {
          "name": "[strings] indexof() with cast to numeric type",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Customers?$filter=contains(Email,'@')&$compute=cast(indexof(Email,'@'),Edm.Int32) as AtPos&$select=ContactName,Email&$top=3",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Customers"
              ],
              "query": [
                {
                  "key": "$filter",
                  "value": "contains(Email,'@')"
                },
                {
                  "key": "$compute",
                  "value": "cast(indexof(Email,'@'),Edm.Int32) as AtPos"
                },
                {
                  "key": "$select",
                  "value": "ContactName,Email"
                },
                {
                  "key": "$top",
                  "value": "3"
                }
              ]
            },
            "description": "From: Part 1 \u2014 A function with context-specific behavior\n\nDemonstrates: wrapping `indexof` in `cast(...,Edm.Int32)` to coerce the string-typed return value to a numeric. Useful when the position needs to be used arithmetically or compared as a number.\n\nExpected: 3 rows with `AtPos` as a JSON number (no quotes)."
          },
          "response": []
        },
        {
          "name": "[strings] indexof() WRONG in $filter \u2014 fails 8309",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Customers?$filter=indexof(ContactName,'Saito') ge 0&$top=1",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Customers"
              ],
              "query": [
                {
                  "key": "$filter",
                  "value": "indexof(ContactName,'Saito') ge 0"
                },
                {
                  "key": "$top",
                  "value": "1"
                }
              ]
            },
            "description": "From: Part 1 \u2014 A function with context-specific behavior\n\nDemonstrates: `indexof` does NOT work inside `$filter`. The same function that works in `$compute` fails here with `8309 \"An expression contains incompatible data types\"`.\n\nWorkaround: for membership tests, use `contains()`. For position-based work, compute in `$compute` and slice client-side.\n\nExpected: 8309 error, no rows."
          },
          "response": []
        },
        {
          "name": "[strings] indexof() + substring() \u2014 works WITHOUT arithmetic",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Customers?$filter=contains(Email,'@')&$compute=substring(Email,cast(indexof(Email,'@'),Edm.Int32)) as FromAt&$select=ContactName,Email&$top=3",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Customers"
              ],
              "query": [
                {
                  "key": "$filter",
                  "value": "contains(Email,'@')"
                },
                {
                  "key": "$compute",
                  "value": "substring(Email,cast(indexof(Email,'@'),Edm.Int32)) as FromAt"
                },
                {
                  "key": "$select",
                  "value": "ContactName,Email"
                },
                {
                  "key": "$top",
                  "value": "3"
                }
              ]
            },
            "description": "From: Part 1 \u2014 A function with context-specific behavior\n\nDemonstrates: `indexof` composes with `substring()` directly when no arithmetic is involved. The result includes the `@` because we sliced from its position (e.g. `\"@brookline.example\"`).\n\nExpected: 3 rows with `FromAt` values like `@brookline.example`."
          },
          "response": []
        },
        {
          "name": "[filter] in operator \u2014 string list (ContactName)",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Customers?$filter=ContactName in ('Victor Saito','Maeve Dvorak')",
            "description": "From: Part 1 \u2014 Comparison operators: in\n\nDemonstrates: the `in` operator matching a field against a list of values. Cleaner than chaining multiple `or` conditions when the list grows.\n\nExpected: 2 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 2 matching customers', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(2);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[filter] in operator \u2014 numeric field (UnitPrice)",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=UnitPrice in (107.39,157.78)&$select=ProductName,UnitPrice",
            "description": "From: Part 1 \u2014 Comparison operators: in\n\nDemonstrates: `in` works with numeric (Edm.Decimal) values, not just strings. Returns the exact rows matching those price values.\n\nExpected: 2 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 2 products matching those exact prices', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(2);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[filter.alias] parameter alias \u2014 basic (@p1 in $filter)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Products?$filter=UnitPrice gt @p1&@p1=500&$select=ProductName,UnitPrice&$orderby=UnitPrice desc",
              "host": ["{{base_url}}"],
              "path": ["{{filename}}", "Products"],
              "query": [
                { "key": "$filter", "value": "UnitPrice gt @p1" },
                { "key": "@p1", "value": "500" },
                { "key": "$select", "value": "ProductName,UnitPrice" },
                { "key": "$orderby", "value": "UnitPrice desc" }
              ]
            },
            "description": "From: Part 1 \u2014 Comparison operators: parameter aliases\n\nDemonstrates: defining a value once as `@p1=500` in the query string and referencing it by name inside `$filter`. Useful when the same value appears in multiple conditions.\n\nIMPORTANT: The `@` prefix must be a literal `@` in the query parameter name \u2014 do not percent-encode it as `%40`. The server identifies aliases by the literal `@` character; encoding it causes `8309 \"field '@p1' does not exist\"`.\n\nExpected: 10 rows (products over $500)."
          },
          "response": [],
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 10 products over $500', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(10);",
                  "});",
                  "pm.test('All returned prices are above 500', function () {",
                  "    var json = pm.response.json();",
                  "    json.value.forEach(row => pm.expect(row.UnitPrice).to.be.above(500));",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[filter.alias] parameter alias \u2014 arithmetic on alias (@p1 mul 2)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Products?$filter=UnitPrice gt @p1 and UnitPrice le @p1 mul 2&@p1=50&$select=ProductName,UnitPrice",
              "host": ["{{base_url}}"],
              "path": ["{{filename}}", "Products"],
              "query": [
                { "key": "$filter", "value": "UnitPrice gt @p1 and UnitPrice le @p1 mul 2" },
                { "key": "@p1", "value": "50" },
                { "key": "$select", "value": "ProductName,UnitPrice" }
              ]
            },
            "description": "From: Part 1 \u2014 Comparison operators: parameter aliases\n\nDemonstrates: the same alias referenced twice in one filter, with arithmetic (`@p1 mul 2`). Returns products in the $50\u2013$100 price range. `@p1 mul 2` evaluates to 100 at query time.\n\nExpected: 101 rows."
          },
          "response": [],
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 101 products in the $50-$100 range', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(101);",
                  "});",
                  "pm.test('All prices are between 50 and 100', function () {",
                  "    var json = pm.response.json();",
                  "    json.value.forEach(row => {",
                  "        pm.expect(row.UnitPrice).to.be.above(50);",
                  "        pm.expect(row.UnitPrice).to.be.at.most(100);",
                  "    });",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[strings] indexof() + substring() WRONG \u2014 composition with arithmetic fails 8310",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Customers?$filter=contains(Email,'@')&$compute=substring(Email,cast(indexof(Email,'@'),Edm.Int32) add 1) as Domain&$select=ContactName,Email&$top=3",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Customers"
              ],
              "query": [
                {
                  "key": "$filter",
                  "value": "contains(Email,'@')"
                },
                {
                  "key": "$compute",
                  "value": "substring(Email,cast(indexof(Email,'@'),Edm.Int32) add 1) as Domain"
                },
                {
                  "key": "$select",
                  "value": "ContactName,Email"
                },
                {
                  "key": "$top",
                  "value": "3"
                }
              ]
            },
            "description": "From: Part 1 \u2014 A function with context-specific behavior\n\nDemonstrates: the natural follow-up \u2014 `indexof + 1` to slice past the `@` \u2014 fails with `8310 \"internal data formatting error\"`. The error is loud but unhelpful: it doesn't say the cause is the arithmetic on the `indexof` result inside `substring`'s position argument.\n\nWorkaround: fetch `indexof(Email,'@') as AtPos` alongside `Email` and slice client-side.\n\nExpected: 8310 error."
          },
          "response": []
        }
      ]
    },
    {
      "name": "Part 2 \u2014 Server-Side Aggregation",
      "description": "Server-side aggregation through `$apply`. `countdistinct` cost scales with the number of rows scanned. On large unfiltered tables it can take many minutes \u2014 see individual request descriptions for runtime notes. Plain `sum`/`average`/`min`/`max` on bare fields is essentially free even on the 16K-row OrderLines table.",
      "item": [
        {
          "name": "[apply.aggregate] simplest \u2014 sum UnitPriceAtSale",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/OrderLines?$apply=aggregate(UnitPriceAtSale with sum as TotalPrice)",
            "description": "From: Part 2 \u2014 The simplest case\n\nDemonstrates: the most basic aggregation form. The alias (`as TotalPrice`) is required. The alias is response-only \u2014 do NOT put it in $select.\n\nExpected: TotalPrice \u2248 2625741.40"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('TotalPrice \u2248 2625741.40', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value[0].TotalPrice).to.be.closeTo(2625741.40, 1);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[apply.groupby] order count by ShippingMethod",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$apply=groupby((ShippingMethod),aggregate(OrderID with countdistinct as OrderCount))",
            "description": "From: Part 2 \u2014 The grouping case\n\nDemonstrates: `groupby` with a `countdistinct` aggregate over the primary key. A bare `groupby((dim))` without an aggregate clause does NOT work on FileMaker 22 \u2014 include a trivial aggregate as shown.\n\nExpected: 5 groups with these counts: Pickup 1231, Overnight 1224, Ground 1196, Express 1177, Freight 1172.\n\n**Runtime note (measured):** ~14 seconds against the 6,000-row Orders table. Tolerable for a one-off but not instant \u2014 `countdistinct` cost scales with rows scanned. See \"When to use `countdistinct`\" in Part 2."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('5 shipping methods returned', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(5);",
                  "});",
                  "pm.test('OrderCounts sum to 6000', function () {",
                  "    var json = pm.response.json();",
                  "    var total = json.value.reduce((s, r) => s + r.OrderCount, 0);",
                  "    pm.expect(total).to.equal(6000);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[apply.multi] three aggregates in one call",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/OrderLines?$apply=aggregate(Quantity with sum as TotalUnits, UnitPriceAtSale with average as MeanUnitPrice, LineID with countdistinct as LineCount)",
            "description": "From: Part 2 \u2014 The multi-aggregate form\n\nDemonstrates: comma-separating multiple aggregations inside a single `aggregate()` call. Atomic snapshot \u2014 guaranteed consistent across the three numbers.\n\nExpected: TotalUnits 34246, MeanUnitPrice \u2248 165.79, LineCount 15838\n\n**Runtime note (measured):** ~8.5 minutes against the 16,000-row OrderLines table. The bottleneck is `countdistinct(LineID)` \u2014 `sum` and `average` alone return in under 2 seconds. Don't think the request hung; this is a multi-minute query by design. See \"When to use `countdistinct`\" in Part 2."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Multi-aggregate returns expected values', function () {",
                  "    var json = pm.response.json();",
                  "    var row = json.value[0];",
                  "    pm.expect(row.TotalUnits).to.equal(34246);",
                  "    pm.expect(row.MeanUnitPrice).to.be.closeTo(165.79, 0.1);",
                  "    pm.expect(row.LineCount).to.equal(15838);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[apply.multi.groupby] units and mean price per OrderID",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/OrderLines?$apply=groupby((OrderID),aggregate(Quantity with sum as Units, UnitPriceAtSale with average as MeanPrice))",
            "description": "From: Part 2 \u2014 The multi-aggregate form\n\nDemonstrates: multi-aggregate inside groupby. One row per order with summed quantity and average unit price.\n\nExpected: 6000 rows (one per order).\n\n**Runtime note (measured):** ~4.6 minutes against the 16,000-row OrderLines table grouped by ~6,000 OrderIDs. Cost is dominated by high-cardinality `groupby` here (no `countdistinct` in this query). Group count drives `groupby` cost roughly linearly: 450 groups \u2248 30s, 6,000 groups \u2248 280s."
          }
        },
        {
          "name": "[apply.cast] sum a text-stored numerical value",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$apply=aggregate(cast(MonthlyCostText,Edm.Decimal) with sum as TotalCost)",
            "description": "From: Part 2 \u2014 cast() inside aggregate()\n\nDemonstrates: summing a text-stored numerical value. Without the cast, `aggregate(MonthlyCostText with sum)` returns `8309 \"Parameter number 1 to the function SUM is not of the correct type.\"`\n\nThis single capability eliminates a whole category of fetch-and-reduce workarounds."
          }
        },
        {
          "name": "[apply.alias] reserved-word alias quoted as 'Count'",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/OrderLines?$apply=aggregate(UnitPriceAtSale with sum as %22Count%22)",
            "description": "From: Part 2 \u2014 Reserved-word aliases\n\nDemonstrates: the escape hatch for reserved-word aliases. Bare `as Count` returns `8310 \"internal data formatting error\"`. Quoting it (URL-encoded) lets it through. The response key comes back unquoted.\n\nReserved words that fail with 8310 include: Count, Sum, Avg, Max, Min, Value, Date, User, Table, Time, Select, Null, Where, Group, Order, Having, Cast, Timestamp.\n\nThe easier path: pick aliases that don't collide. Total, Subtotal, Tally, Rollup, RecordCount, etc. are all safe."
          }
        },
        {
          "name": "[apply.filter] Q4 2025 orders by ShippingMethod",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=OrderDate ge 2025-10-01 and OrderDate le 2025-12-31&$apply=groupby((ShippingMethod),aggregate(OrderID with countdistinct as OrderCount))",
            "description": "From: Part 2 \u2014 Filtering before aggregation\n\nDemonstrates: scoping an aggregate with a top-level `$filter`. `$filter` and `$apply` are separate top-level options joined with `&` \u2014 `filter()` as a transformation inside `$apply` is NOT supported on FileMaker 22.\n\nNote unquoted date literals: `2025-10-01` not `'2025-10-01'`. Discipline rule: always unquoted on date filters.\n\nExpected: 5 rows summing to 846 (Q4 2025 had a deliberate 25% spike in the seed data)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('5 shipping methods returned for Q4 2025', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(5);",
                  "});",
                  "pm.test('Q4 2025 OrderCounts sum to 846', function () {",
                  "    var json = pm.response.json();",
                  "    var total = json.value.reduce((s, r) => s + r.OrderCount, 0);",
                  "    pm.expect(total).to.equal(846);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[apply.expression] sum of expression \u2014 total inventory value (Products)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Products?$apply=aggregate(UnitPrice mul StockQty with sum as InventoryValue)",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Products"
              ],
              "query": [
                {
                  "key": "$apply",
                  "value": "aggregate(UnitPrice mul StockQty with sum as InventoryValue)"
                }
              ]
            },
            "description": "From: Part 2 \u2014 Inline expressions and cast() inside aggregate()\n\nDemonstrates: `aggregate()` accepts arithmetic expressions, not just bare fields. Returns the total inventory value (UnitPrice \u00d7 StockQty summed across all products) in one row.\n\nExpected: `InventoryValue` \u2248 18,569,284.46. Fast (~1s, 450 rows)."
          },
          "response": []
        },
        {
          "name": "[apply.expression.groupby] revenue per product (OrderLines)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/OrderLines?$apply=groupby((ProductID),aggregate(Quantity mul UnitPriceAtSale with sum as TotalRev))",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "OrderLines"
              ],
              "query": [
                {
                  "key": "$apply",
                  "value": "groupby((ProductID),aggregate(Quantity mul UnitPriceAtSale with sum as TotalRev))"
                }
              ]
            },
            "description": "From: Part 2 \u2014 Inline expressions and cast() inside aggregate()\n\nDemonstrates: arithmetic expressions inside `aggregate()` work inside `groupby()`. One row per product with revenue summed across all order lines for that product.\n\nExpected: 450 rows.\n\n**Runtime note (measured):** ~30 seconds \u2014 high-cardinality `groupby` on 16K OrderLines."
          },
          "response": []
        },
        {
          "name": "[apply.expression.cast] cast in expression for text-stored numerical value (Products)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Products?$apply=aggregate(cast(MonthlyCostText,Edm.Decimal) mul 12 with sum as TotalAnnualCost)",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Products"
              ],
              "query": [
                {
                  "key": "$apply",
                  "value": "aggregate(cast(MonthlyCostText,Edm.Decimal) mul 12 with sum as TotalAnnualCost)"
                }
              ]
            },
            "description": "From: Part 2 \u2014 Inline expressions and cast() inside aggregate()\n\nDemonstrates: `cast()` works inside arithmetic expressions inside `aggregate()`. `MonthlyCostText` is text-stored on Products; this casts it to Decimal, multiplies by 12, and sums.\n\nExpected: `TotalAnnualCost` \u2248 22,902.84. Fast (~1s)."
          },
          "response": []
        },
        {
          "name": "[apply.expression.multi] mixed bare field, expression, and countdistinct (Products)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Products?$apply=aggregate(StockQty with sum as TotalStock,UnitPrice mul StockQty with sum as TotalValue,ProductID with countdistinct as ProdCount)",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Products"
              ],
              "query": [
                {
                  "key": "$apply",
                  "value": "aggregate(StockQty with sum as TotalStock,UnitPrice mul StockQty with sum as TotalValue,ProductID with countdistinct as ProdCount)"
                }
              ]
            },
            "description": "From: Part 2 \u2014 Inline expressions and cast() inside aggregate()\n\nDemonstrates: bare fields, arithmetic expressions, and `countdistinct` can all coexist in the same multi-aggregate. Three values returned in one call.\n\nExpected: `TotalStock` \u2248 115,519, `TotalValue` \u2248 18,569,284.46, `ProdCount` = 450.\n\nFast (~1s) \u2014 `countdistinct` over 450 rows is cheap. The same pattern on a 16K-row table would be slow."
          },
          "response": []
        },
        {
          "name": "[apply.expression.groupby.cast] inventory value by category (Products)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/Products?$apply=groupby((Category),aggregate(UnitPrice mul StockQty with sum as InventoryValue))",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "Products"
              ],
              "query": [
                {
                  "key": "$apply",
                  "value": "groupby((Category),aggregate(UnitPrice mul StockQty with sum as InventoryValue))"
                }
              ]
            },
            "description": "From: Part 2 \u2014 Inline expressions and cast() inside aggregate()\n\nDemonstrates: the \"value by category\" pattern, working server-side because both fields live on Products. (For the cross-table version against OrderLines \u00d7 Products by Category, you'd need `$crossjoin` and client-side aggregation \u2014 see Part 3.)\n\nExpected: ~7 rows, one per category. Fast (~1s)."
          },
          "response": []
        }
      ]
    },
    {
      "name": "Part 3 \u2014 Joins and Computed Fields",
      "description": "$compute, $expand, and $crossjoin. Computing values in the response, joining tables with a graph relationship, and joining tables that share a logical key but no relationship.",
      "item": [
        {
          "name": "[compute] LineTotal as Quantity mul UnitPriceAtSale",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/OrderLines?$compute=Quantity mul UnitPriceAtSale as LineTotal&$select=LineID,Quantity,UnitPriceAtSale&$top=5",
            "description": "From: Part 3 \u2014 $compute\n\nDemonstrates: adding a computed field to the response. Note `mul` not `*` \u2014 operators are word-form (`add`, `sub`, `mul`, `div`, `mod`).\n\nThe `LineTotal` alias rides along in the response automatically; do NOT put it in $select."
          }
        },
        {
          "name": "[compute.cast] type a text-stored numerical value in the response",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$compute=cast(MonthlyCostText,Edm.Decimal) as MonthlyCost&$select=ProductID,ProductName,MonthlyCostText&$top=10",
            "description": "From: Part 3 \u2014 $compute\n\nDemonstrates: typing a value in the response without changing the schema. The response includes both the original text-stored field and a properly-typed decimal version."
          }
        },
        {
          "name": "[compute] multiple computes in one call",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/OrderLines?$compute=Quantity mul UnitPriceAtSale as LineTotal,UnitPriceAtSale mul DiscountPct div 100 as DiscountAmount",
            "description": "From: Part 3 \u2014 $compute\n\nDemonstrates: comma-separating multiple computed fields. Both aliases ride along in the response."
          }
        },
        {
          "name": "[expand] orders with related Customer (graph relationship)",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$expand=Customers($select=CompanyName,Region)&$select=OrderID,OrderDate,OrderStatus&$top=3",
            "description": "From: Part 3 \u2014 $expand\n\nDemonstrates: joining via the FileMaker relationship graph. Requires Customers to be related to Orders via CustomerID in the graph (set this up per the SEED_DATA_README).\n\nNotes:\n- One-to-one relationships still come back wrapped in a single-element array.\n- $top and $count=true are NOT supported inside $expand.\n- If the relationship doesn't exist in the graph, server returns -1019.\n- If the navigation property name is wrong, server returns -1021."
          }
        },
        {
          "name": "[crossjoin] OrderLines \u00d7 Products with $expand($select)",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/$crossjoin(OrderLines,Products)?$filter=OrderLines/ProductID eq Products/ProductID&$expand=OrderLines($select=Quantity,UnitPriceAtSale),Products($select=Category,ProductName)&$top=3",
            "description": "From: Part 3 \u2014 $crossjoin\n\nDemonstrates: joining two tables that aren't related in the graph using the documented Claris pattern \u2014 `$crossjoin` in the path slot, `$filter` for the join condition, `$expand($select=...)` per side to project fields.\n\nNote: `$apply` does NOT compose with `$crossjoin` on FileMaker 22 \u2014 it returns -1002. For aggregation across a join, project the joined rows and sum client-side.\n\nWithout $top, this matches all 15838 OrderLines."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Top 3 returns 3 joined rows', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(3);",
                  "});",
                  "pm.test('Joined rows include both OrderLines and Products fields', function () {",
                  "    var json = pm.response.json();",
                  "    var row = json.value[0];",
                  "    pm.expect(row).to.have.property('Quantity');",
                  "    pm.expect(row).to.have.property('UnitPriceAtSale');",
                  "    pm.expect(row).to.have.property('Category');",
                  "    pm.expect(row).to.have.property('ProductName');",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[crossjoin.3way] Orders \u00d7 OrderLines \u00d7 Products, Delivered only",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/$crossjoin(Orders,OrderLines,Products)?$filter=Orders/OrderID eq OrderLines/OrderID and OrderLines/ProductID eq Products/ProductID and Orders/OrderStatus eq 'Delivered'&$expand=Orders($select=OrderID,OrderStatus),OrderLines($select=Quantity),Products($select=Category)&$top=3",
            "description": "From: Part 3 \u2014 $crossjoin\n\nDemonstrates: a three-way crossjoin with the documented `$expand($select=...)` pattern. Join conditions and the business filter (OrderStatus eq 'Delivered') all sit in the same compound `$filter`. Order of the join conditions doesn't matter \u2014 the engine plans the joins.\n\nWithout $top, this matches 9471 order lines belonging to delivered orders."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Top 3 returns 3 joined rows', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(3);",
                  "});",
                  "pm.test('All rows have OrderStatus=Delivered', function () {",
                  "    var json = pm.response.json();",
                  "    json.value.forEach(row => pm.expect(row.OrderStatus).to.equal('Delivered'));",
                  "});",
                  "pm.test('Joined rows include fields from all three tables', function () {",
                  "    var json = pm.response.json();",
                  "    var row = json.value[0];",
                  "    pm.expect(row).to.have.property('OrderID');",
                  "    pm.expect(row).to.have.property('Quantity');",
                  "    pm.expect(row).to.have.property('Category');",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[crossjoin.vendors] active vendors with payments",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/$crossjoin(Vendors,VendorPayments)?$filter=Vendors/VendorID eq VendorPayments/VendorID and Vendors/Active eq 1&$expand=Vendors($select=VendorName),VendorPayments($select=AmountPaid,PaymentDate,PaymentReference)&$top=10",
            "description": "From: Part 3 \u2014 A real example: vendor spend\n\nDemonstrates: joining Vendors and VendorPayments (no graph relationship in the test schema). Returns each payment with its vendor name attached, ready to roll up client-side into vendor totals, monthly summaries, etc.\n\nWithout $top, this returns 3374 active-vendor payment rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Top 10 returns 10 joined rows', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(10);",
                  "});",
                  "pm.test('All rows include VendorName and AmountPaid', function () {",
                  "    var json = pm.response.json();",
                  "    json.value.forEach(row => {",
                  "        pm.expect(row).to.have.property('VendorName');",
                  "        pm.expect(row).to.have.property('AmountPaid');",
                  "    });",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[crossjoin.contains] active vendors with INV-reference payments",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/$crossjoin(Vendors,VendorPayments)?$filter=Vendors/VendorID eq VendorPayments/VendorID and Vendors/Active eq 1 and contains(VendorPayments/PaymentReference,'INV')&$expand=Vendors($select=VendorName),VendorPayments($select=AmountPaid,PaymentDate,PaymentReference)&$top=10",
            "description": "From: Part 3 \u2014 A real example: vendor spend\n\nDemonstrates: combining join conditions, business filters, and `contains()` inside a cross-join filter. All canonical OData string functions (Part 1) work here.\n\nWithout $top, this returns 1866 active-vendor invoice payment rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Top 10 returns 10 joined rows', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(10);",
                  "});",
                  "pm.test('All payment references contain INV', function () {",
                  "    var json = pm.response.json();",
                  "    json.value.forEach(row => pm.expect(row.PaymentReference).to.include('INV'));",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[crossjoin.collision] duplicate field name \u2014 Active selected from both sides",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/$crossjoin(Customers,Vendors)?$filter=Vendors/VendorID eq 'VEND-1E079B596E'&$expand=Customers($select=CompanyName,Active),Vendors($select=VendorName,Active)&$top=1",
              "host": ["{{base_url}}"],
              "path": ["{{filename}}", "$crossjoin(Customers,Vendors)"],
              "query": [
                { "key": "$filter", "value": "Vendors/VendorID eq 'VEND-1E079B596E'" },
                { "key": "$expand", "value": "Customers($select=CompanyName,Active),Vendors($select=VendorName,Active)" },
                { "key": "$top", "value": "1" }
              ]
            },
            "description": "From: Part 3 \u2014 Field-name collisions in $expand selections\n\nDemonstrates: selecting a field of the same name from both sides of a crossjoin produces literal duplicate JSON keys in the raw response \u2014 `\"Active\": 1, \"Active\": 0`. Both Customers and Vendors have an `Active` field; this query selects both.\n\nThe raw HTTP body contains `\"Active\": 1, ..., \"Active\": 0`. Lenient parsers (Python json.loads, JavaScript JSON.parse) silently keep the last value \u2014 the one from Vendors, the second table in the join \u2014 and discard the Customer value without warning. Strict parsers (FileMaker's own JSON handling) raise `Duplicate key: 'Active'` and refuse the response.\n\nFix: select non-overlapping field lists. If you need both values, use different alias strategies client-side.\n\nExpected: 1 row with a single `Active` key (Vendor's value: 0 for Apex Inc, an inactive vendor)."
          },
          "response": []
        },
        {
          "name": "[crossjoin.no-expand] no $expand \u2014 all fields including keys appear flat",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/$crossjoin(Customers,Vendors)?$filter=Vendors/VendorID eq 'VEND-1E079B596E'&$top=1",
              "host": ["{{base_url}}"],
              "path": ["{{filename}}", "$crossjoin(Customers,Vendors)"],
              "query": [
                { "key": "$filter", "value": "Vendors/VendorID eq 'VEND-1E079B596E'" },
                { "key": "$top", "value": "1" }
              ]
            },
            "description": "From: Part 3 \u2014 Field-name collisions in $expand selections\n\nDemonstrates: omitting `$expand($select=...)` entirely causes every field from every joined table to appear flat in the response \u2014 including OData key fields like `CustomerID` and `VendorID`. Any field name shared across the tables collapses silently by the same last-wins rule.\n\nIn this response you can see `CustomerID`, `VendorID`, and all other fields from both tables. The `Active` field (present on both) appears once \u2014 Vendor's value wins because Vendors is the second table.\n\nThe fix is to always use `$expand($select=...)` to explicitly project only the fields you need."
          },
          "response": []
        },
        {
          "name": "[compute+apply] WRONG \u2014 $compute and $apply don't compose",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/{{filename}}/OrderLines?$compute=Quantity mul UnitPriceAtSale as LineRev&$apply=groupby((ProductID),aggregate(LineRev with sum as TotalRev))",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "{{filename}}",
                "OrderLines"
              ],
              "query": [
                {
                  "key": "$compute",
                  "value": "Quantity mul UnitPriceAtSale as LineRev"
                },
                {
                  "key": "$apply",
                  "value": "groupby((ProductID),aggregate(LineRev with sum as TotalRev))"
                }
              ]
            },
            "description": "From: Part 3 \u2014 `$compute` and `$apply` don't compose\n\nDemonstrates: putting `$compute` and `$apply` in the same querystring fails. The most common surface is `8309 \"All non-aggregated field references must be in the groupby clause\"`, which doesn't say the underlying problem is the option mix. Other variants surface as `8309 \"field 'LineRev' does not exist...\"` (when the alias is referenced inside `$apply`) or `-1002` parse errors.\n\nWorkaround: if the goal is just to compute and aggregate (not also return per-row), use the inline-expression-in-aggregate form from Part 2: `aggregate(Quantity mul UnitPriceAtSale with sum as TotalRev)`.\n\nExpected: 8309 error."
          },
          "response": []
        }
      ]
    },
    {
      "name": "Part 4 \u2014 Silent Failures",
      "description": "Queries that return 200 OK and the wrong answer. Each section has a 'wrong' query and a 'right' query so you can see the difference yourself.\n\n**Important:** the queries labeled WRONG are deliberately included to demonstrate the silent failure. Run them and compare to the correct form to see how they mislead.",
      "item": [
        {
          "name": "[ne.nulls] WRONG \u2014 ne 'Cancelled' silently excludes nulls",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=OrderStatus ne 'Cancelled'&$count=true&$top=1",
            "description": "From: Part 4 \u2014 The three-valued logic problem\n\nDEMONSTRATES THE BUG: Returns 5419 instead of the expected 5595. The 176 missing rows have null OrderStatus. SQL three-valued logic: `null ne 'Cancelled'` evaluates to `null`, not `true`.\n\nExpected (silent failure): @count: 5419"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Demonstrates silent null exclusion: @count is 5419', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(5419);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[ne.nulls] RIGHT \u2014 explicit null inclusion",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=OrderStatus ne 'Cancelled' or OrderStatus eq null&$count=true&$top=1",
            "description": "From: Part 4 \u2014 The three-valued logic problem\n\nThe correct form. Explicitly includes null rows.\n\nExpected: @count: 5595"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('@count is 5595 when nulls are included', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(5595);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[lambda] WRONG \u2014 slash-form predicate silently dropped",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=OrderNotes/any(n:n eq 'urgent')&$count=true&$top=1",
            "description": "From: Part 4 \u2014 Unsupported features that fail quietly\n\nDEMONSTRATES THE BUG: Lambda operators (`any` and `all`) are listed as unsupported in the Claris OData guide. The slash-form parses, executes, returns 200 OK with the predicate silently dropped \u2014 equivalent to `$filter=true`. Returns every row in the table.\n\nThe related dot-notation form `any(field:...)` fails loudly with 8310. The slash-form is the silent one.\n\nExpected (silent failure): @count: 6000 (the entire Orders table)"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Demonstrates silent lambda drop: @count is 6000 (whole table)', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(6000);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[lambda] RIGHT \u2014 contains() for membership",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=contains(OrderNotes,'urgent')",
            "description": "From: Part 4 \u2014 Unsupported features that fail quietly\n\nThe correct form for membership tests on tag/list fields. `contains()` works correctly and predictably."
          }
        },
        {
          "name": "[apostrophe] RIGHT \u2014 SQL-style doubling",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=contains(OrderNotes,'O''Brien')",
            "description": "From: Part 4 \u2014 URL-encoded apostrophes\n\nThe correct way to escape an apostrophe inside a single-quoted string literal: SQL-style doubling \u2014 two single quotes in a row.\n\nExpected: 80 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 80 O\\'Brien orders', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(80);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[apostrophe] WRONG \u2014 URL-encoded %27 silently dropped",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=contains(OrderNotes,'O%27Brien')",
            "description": "From: Part 4 \u2014 URL-encoded apostrophes\n\nDEMONSTRATES THE BUG: %27 is silently stripped at decode time. Pattern becomes 'OBrien' (no apostrophe), which matches zero rows.\n\nNote the asymmetry: `&` does NOT need encoding inside string literals \u2014 `%26` is harmless but unnecessary, while `%27` is actively wrong.\n\nExpected (silent failure): 0 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Demonstrates silent %27 drop: 0 rows', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(0);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[date.cast] RIGHT \u2014 unquoted date literal on text-stored field",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=cast(NextBillDateText,Edm.Date) ge 2026-01-01 and cast(NextBillDateText,Edm.Date) lt 2026-02-01",
            "description": "From: Part 4 \u2014 Date strings, type casts, and a quoted-literal trap\n\nThe correct form. Unquoted date literals are treated as OData temporal values.\n\nExpected: 268 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 268 orders with NextBillDateText in Jan 2026', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(268);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[date.cast] WRONG \u2014 quoted date literal falls back to string compare",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=cast(NextBillDateText,Edm.Date) ge '2026-01-01' and cast(NextBillDateText,Edm.Date) lt '2026-02-01'",
            "description": "From: Part 4 \u2014 Date strings, type casts, and a quoted-literal trap\n\nDEMONSTRATES THE BUG: Quoted literal causes fallback to lexical string comparison on the underlying text-stored value. Returns plausible-looking but wrong rows. The count is non-zero, not the full table \u2014 easy to miss without an expected number to reconcile against.\n\nDiscipline rule: always unquoted date literals on date filters."
          }
        },
        {
          "name": "[count] WORKS \u2014 count with top=5",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=OrderStatus eq 'Cancelled'&$count=true&$top=5",
            "description": "From: Part 4 \u2014 $count=true & $top=0\n\nDemonstrates that `$count=true` works correctly when `$top >= 1`.\n\nExpected: @count: 405"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('@count is 405 with $top=5', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(405);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[count] WRONG \u2014 $top=0 always returns @count: 0",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$filter=OrderStatus eq 'Cancelled'&$count=true&$top=0",
            "description": "From: Part 4 \u2014 $count=true & $top=0\n\nDEMONSTRATES THE BUG: The cross-OData 'cheap row count' trick of `$count=true&$top=0` doesn't work on FileMaker 22. Returns @count: 0 regardless of filter.\n\nWorkaround: use `$top=1` (pay for one row, get the correct count) or the path-based form `tablename/$count`.\n\nExpected (silent failure): @count: 0"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Demonstrates broken $top=0: @count is wrongly 0', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json['@count']).to.equal(0);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[count] path-based total count",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders/$count",
            "description": "From: Part 4 \u2014 $count=true & $top=0\n\nDemonstrates the path-based count form. Returns a bare integer (not a JSON envelope). The querystring is ignored \u2014 this form does NOT respect $filter. For unfiltered totals it's the cleanest option.\n\nExpected: 6000 (bare integer in response body)"
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Path-based count returns 6000', function () {",
                  "    pm.expect(pm.response.text().trim()).to.equal('6000');",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        },
        {
          "name": "[typo] WRONG \u2014 misspelled $flter silently dropped",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Orders?$flter=OrderStatus eq 'Cancelled'&$top=5",
            "description": "From: Part 4 \u2014 Misspelled query options\n\nDEMONSTRATES THE BUG: The typo `$flter` (missing 'i') is silently dropped. Server executes against an empty filter and returns the full table.\n\nNo warning, no 400, no log entry visible to the client. The unrecognized option just disappears.\n\nDefense: keep query options in a constant or enum, never type them as bare strings in client code.\n\nExpected (silent failure): 5 random orders, ignoring the filter completely."
          }
        },
        {
          "name": "[boolean] WRONG \u2014 bareword 'true' parsed as field name",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=Discontinued eq true",
            "description": "From: Part 4 \u2014 Booleans stored as 'true'/'false' text\n\nThis fails loudly with `8309 \"field 'true' does not exist\"`. Bareword `true` parses as a field name."
          }
        },
        {
          "name": "[boolean] WRONG \u2014 cast to Edm.Boolean fails",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=cast(Discontinued,Edm.Boolean) eq 'true'",
            "description": "From: Part 4 \u2014 Booleans stored as 'true'/'false' text\n\nThis fails loudly with `8309 \"An expression contains incompatible data types\"`. The cast surface is wide but doesn't include text-stored 'true'/'false'."
          }
        },
        {
          "name": "[boolean] RIGHT \u2014 plain string equality",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/{{filename}}/Products?$filter=Discontinued eq 'true'",
            "description": "From: Part 4 \u2014 Booleans stored as 'true'/'false' text\n\nThe correct form. Treat the field as what it actually is \u2014 a text field whose values happen to be the strings 'true' and 'false'.\n\nLesson: reach for cast() when the server actively asks for it, not preemptively.\n\nExpected: 71 rows."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Returns 71 discontinued products', function () {",
                  "    var json = pm.response.json();",
                  "    pm.expect(json.value).to.have.lengthOf(71);",
                  "});"
                ],
                "type": "text/javascript"
              }
            }
          ]
        }
      ]
    }
  ]
}