|
1 | 1 | import { describe, it, expect } from 'vitest'; |
2 | | -import type { IDataDriver } from './data-driver'; |
| 2 | +import type { DriverQuery, IDataDriver } from './data-driver'; |
| 3 | +import type { QueryAST } from '../data/query.zod'; |
| 4 | +import type { DriverOptions } from '../data/driver.zod'; |
3 | 5 |
|
4 | 6 | describe('IDataDriver', () => { |
5 | 7 | it('should allow creating a conforming mock implementation', () => { |
@@ -143,4 +145,122 @@ describe('IDataDriver', () => { |
143 | 145 | expect('findStream' in legacyShaped).toBe(true); |
144 | 146 | }); |
145 | 147 | }); |
| 148 | + |
| 149 | + // =========================================================================== |
| 150 | + // DriverQuery — the AST no longer repeats the object name (#5181) |
| 151 | + // =========================================================================== |
| 152 | + // |
| 153 | + // Every pin below is resolved by tsc, not by vitest: reverting the change |
| 154 | + // (`query: DriverQuery` back to `query: QueryAST`) makes the `@ts-expect-error` |
| 155 | + // directives unused, and an unused directive is itself an error, so |
| 156 | + // `pnpm --filter @objectstack/spec typecheck` goes red. This file carries no |
| 157 | + // entry in `test-typecheck-debt.json`, which is what makes "zero errors" the |
| 158 | + // measurable baseline these pins move away from. The `expect()` calls only |
| 159 | + // give the assertions a home vitest will run. |
| 160 | + |
| 161 | + describe('DriverQuery', () => { |
| 162 | + it('does not carry `object` at all — argument one is the only spelling', () => { |
| 163 | + type ObjectDropped = 'object' extends keyof DriverQuery ? never : 'dropped'; |
| 164 | + const dropped: ObjectDropped = 'dropped'; |
| 165 | + // Everything else survives: this is a subtraction of one key, not a new dialect. |
| 166 | + type WhereKept = 'where' extends keyof DriverQuery ? 'kept' : never; |
| 167 | + const kept: WhereKept = 'kept'; |
| 168 | + expect([dropped, kept]).toEqual(['dropped', 'kept']); |
| 169 | + }); |
| 170 | + |
| 171 | + it('is what all six query-taking methods actually declare', () => { |
| 172 | + // This pin reads the parameter off the CONTRACT rather than off the alias, |
| 173 | + // and that is the point: a revert that puts `QueryAST` back on one |
| 174 | + // signature while leaving `DriverQuery` defined would sail past every |
| 175 | + // alias-scoped assertion in this block. Here that slot resolves to `never` |
| 176 | + // and the line goes red — per method, so the message names which one. |
| 177 | + type DropsObject<T> = 'object' extends keyof T ? never : 'dropped'; |
| 178 | + const perMethod: [ |
| 179 | + DropsObject<Parameters<IDataDriver['find']>[1]>, |
| 180 | + DropsObject<Parameters<IDataDriver['findOne']>[1]>, |
| 181 | + DropsObject<NonNullable<Parameters<IDataDriver['count']>[1]>>, |
| 182 | + DropsObject<Parameters<NonNullable<IDataDriver['updateMany']>>[1]>, |
| 183 | + DropsObject<Parameters<NonNullable<IDataDriver['deleteMany']>>[1]>, |
| 184 | + DropsObject<Parameters<NonNullable<IDataDriver['explain']>>[1]>, |
| 185 | + ] = ['dropped', 'dropped', 'dropped', 'dropped', 'dropped', 'dropped']; |
| 186 | + expect(perMethod).toHaveLength(6); |
| 187 | + }); |
| 188 | + |
| 189 | + it('lets a caller pass only the query, which is what forced the casts', () => { |
| 190 | + // Before #5181 this literal did not compile (`object` was required), so a |
| 191 | + // caller holding just a `where` reached for `as any` — and lost the type |
| 192 | + // checking on everything else in the same stroke (cloud#1053, 20 sites). |
| 193 | + const q: DriverQuery = { where: { status: 'open' }, limit: 10 }; |
| 194 | + expect(q.limit).toBe(10); |
| 195 | + }); |
| 196 | + |
| 197 | + it('rejects the redundant object key in a call-site literal', () => { |
| 198 | + // The excess-property check is the whole enforcement: writing the object |
| 199 | + // name twice is now a compile error rather than a convention nobody could |
| 200 | + // enforce. It is also what stops the two spellings from disagreeing — |
| 201 | + // the hazard the engine spends a key order on (`{ ...query, object }`) |
| 202 | + // and the wire layer spends a 400 on (`QUERY_OBJECT_MISMATCH`). |
| 203 | + // @ts-expect-error - 'object' does not exist in type 'DriverQuery' |
| 204 | + const redundant: DriverQuery = { object: 'account', where: { status: 'open' } }; |
| 205 | + expect(redundant).toBeTruthy(); |
| 206 | + }); |
| 207 | + |
| 208 | + it('still accepts a whole QueryAST value, so existing callers do not move', () => { |
| 209 | + // A `QueryAST` variable has every property `DriverQuery` requires and one |
| 210 | + // more; TypeScript admits the extra on any value that is not a fresh |
| 211 | + // literal. This is why the engine's `driver.find(object, ast, …)` needed |
| 212 | + // no edit — only literals written at the call site are re-judged. |
| 213 | + const ast: QueryAST = { object: 'account', where: { status: 'open' } }; |
| 214 | + const asDriverQuery: DriverQuery = ast; |
| 215 | + expect(asDriverQuery.where).toEqual({ status: 'open' }); |
| 216 | + }); |
| 217 | + |
| 218 | + it('keeps `object` inside an expand entry, where it is not redundant', () => { |
| 219 | + // The nested value names the RELATED object — a fact no argument carries. |
| 220 | + const q: DriverQuery = { |
| 221 | + fields: ['title'], |
| 222 | + expand: { owner: { object: 'user', fields: ['name'] } }, |
| 223 | + }; |
| 224 | + expect(q.expand?.owner?.object).toBe('user'); |
| 225 | + |
| 226 | + // @ts-expect-error - a nested expand entry still requires its own `object` |
| 227 | + const missing: DriverQuery = { expand: { owner: { fields: ['name'] } } }; |
| 228 | + expect(missing).toBeTruthy(); |
| 229 | + }); |
| 230 | + |
| 231 | + it('keeps an implementation that still declares the full QueryAST', () => { |
| 232 | + // Method parameters are compared bivariantly, so a driver written against |
| 233 | + // the old signature needs no edit to keep satisfying the contract. What it |
| 234 | + // may no longer do is READ `query.object` — callers are free to omit it — |
| 235 | + // and no driver in this repository does. |
| 236 | + const legacyImplementation: Pick<IDataDriver, 'find' | 'count'> = { |
| 237 | + async find(_object: string, _query: QueryAST, _options?: DriverOptions) { |
| 238 | + return []; |
| 239 | + }, |
| 240 | + async count(_object: string, _query?: QueryAST, _options?: DriverOptions) { |
| 241 | + return 0; |
| 242 | + }, |
| 243 | + }; |
| 244 | + expect(legacyImplementation.count).toBeDefined(); |
| 245 | + }); |
| 246 | + |
| 247 | + it('recovers the checks a blanket cast switched off — but not all of them', () => { |
| 248 | + // What the cast hid and this change gives back: the typed slots. |
| 249 | + // `orderBy` is `SortNode[]`, closed since #4721, so the `direction` |
| 250 | + // spelling that silently sorted the wrong way is a compile error again. |
| 251 | + // @ts-expect-error - spell the direction `order`, never `direction` |
| 252 | + const wrongSortKey: DriverQuery = { orderBy: [{ field: 'created_at', direction: 'desc' }] }; |
| 253 | + expect(wrongSortKey).toBeTruthy(); |
| 254 | + |
| 255 | + // What it does NOT give back, stated here so nobody reads more into the |
| 256 | + // fix than it delivers: `where` is `FilterCondition`, whose index |
| 257 | + // signature is `[key: string]: any` because ANY field name is a legal key. |
| 258 | + // An operator the dialect does not have is therefore still not a type |
| 259 | + // error — `$like` (cloud#1030) reaches the runtime filter compiler and is |
| 260 | + // rejected there, not here. Removing the cast does not close that door; |
| 261 | + // only a closed operator vocabulary would, which is a separate change. |
| 262 | + const unknownOperator: DriverQuery = { where: { name: { $like: 'acme%' } } }; |
| 263 | + expect(unknownOperator.where).toBeTruthy(); |
| 264 | + }); |
| 265 | + }); |
146 | 266 | }); |
0 commit comments