diff --git a/ANALYSIS.md b/ANALYSIS.md index e99db09..e783eba 100644 --- a/ANALYSIS.md +++ b/ANALYSIS.md @@ -53,3 +53,12 @@ Under the source-change test gate, source edits were not retained this run. - Tightened pagination validation in `util/schema.ts`: pagination fields now require finite, non-negative integers. - Added regression tests for negative and infinite/overflow pagination inputs. - Verified tests: `npm test` ✅ (6/6). + +## 2026-02-19 flattened-slot-9 maintenance (logical-operator parity finalization) + +- Ran branch hygiene before edits: `git fetch origin` + merge `origin/main`. +- Completed parity hardening in `util/schema.ts`: + - `AND` / `OR` now accept single-object operands in recursive where builder and exported `Query`. + - `orderBy` normalization now applies consistently across `getQueryInput` and exported `Query`. + - Shared numeric parser now drives pagination values used by both query schema entry points. +- Added matching regression tests in `test/schema.test.ts` and updated util/test docs. diff --git a/test/ANALYSIS.md b/test/ANALYSIS.md index ccb514d..07dd00b 100644 --- a/test/ANALYSIS.md +++ b/test/ANALYSIS.md @@ -13,3 +13,11 @@ - scalar `not` values remain valid. - nested operator-object `not` values (e.g. `{ not: { in: [...] } }`) are accepted. - Test runner wired through repo-defined `npm test` script using Jest (`ts-jest`). + +## 2026-02-19 follow-up + +- Expanded `schema.test.ts` to verify logical operator shape parity: + - `where.AND` and `where.OR` accept single-object forms via both `getQueryInput` and exported `Query`. +- Added order-direction parity tests: + - `' DESC '` normalizes to `'desc'` in both schema paths. + - Invalid values such as `'descending'` are rejected in both schema paths. diff --git a/test/README.md b/test/README.md index c5a156a..0009cdb 100644 --- a/test/README.md +++ b/test/README.md @@ -8,3 +8,10 @@ Current coverage includes: - Pagination bounds validation (reject negative and infinite/overflow values). - Prisma-compatible `where..not` handling for both scalar and nested-operator object forms. - Top-level `where.NOT` compatibility for both single-object and array forms. + +## 2026-02-19 maintenance update + +Added regression coverage for: +- Single-object logical operands on top-level `where.AND` and `where.OR` (both `getQueryInput` and exported `Query`). +- `orderBy` direction normalization (`trim` + lowercase) for both query schema entry points. +- Invalid `orderBy` direction rejection parity across both schema paths. diff --git a/test/schema.test.ts b/test/schema.test.ts index d0dc841..b6c60e9 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -1,7 +1,7 @@ // arken/packages/evolution/packages/protocol/test/schema.test.ts // import { z } from 'zod'; -import { getQueryInput } from '../util/schema'; +import { getQueryInput, Query } from '../util/schema'; describe('util/schema getQueryInput pagination aliases', () => { const queryInput = getQueryInput( @@ -100,3 +100,92 @@ describe('util/schema getQueryInput where not-operator compatibility', () => { }); }); }); + +describe('util/schema logical operator + orderBy normalization parity', () => { + const queryInput = getQueryInput( + z.object({ + status: z.string().optional(), + }) + ); + + it('accepts top-level AND/OR as a single object for getQueryInput', () => { + const parsed = queryInput.parse({ + where: { + AND: { + status: { + equals: 'Active', + }, + }, + OR: { + status: { + equals: 'Paused', + }, + }, + }, + }); + + expect(parsed).toMatchObject({ + where: { + AND: { + status: { + equals: 'Active', + }, + }, + OR: { + status: { + equals: 'Paused', + }, + }, + }, + }); + }); + + it('normalizes orderBy direction casing/whitespace for getQueryInput', () => { + const parsed = queryInput.parse({ orderBy: { createdDate: ' DESC ' } }); + + expect(parsed).toMatchObject({ orderBy: { createdDate: 'desc' } }); + }); + + it('accepts top-level AND/OR as a single object for exported Query schema', () => { + const parsed = Query.parse({ + where: { + AND: { + status: { + equals: 'Active', + }, + }, + OR: { + status: { + equals: 'Paused', + }, + }, + }, + }); + + expect(parsed).toMatchObject({ + where: { + AND: { + status: { + equals: 'Active', + }, + }, + OR: { + status: { + equals: 'Paused', + }, + }, + }, + }); + }); + + it('normalizes orderBy direction casing/whitespace for exported Query schema', () => { + const parsed = Query.parse({ orderBy: { createdDate: ' DESC ' } }); + + expect(parsed).toMatchObject({ orderBy: { createdDate: 'desc' } }); + }); + + it('rejects invalid orderBy direction values', () => { + expect(() => queryInput.parse({ orderBy: { createdDate: 'descending' } })).toThrow(); + expect(() => Query.parse({ orderBy: { createdDate: 'descending' } })).toThrow(); + }); +}); diff --git a/util/ANALYSIS.md b/util/ANALYSIS.md index 0aa3a6a..c9a4053 100644 --- a/util/ANALYSIS.md +++ b/util/ANALYSIS.md @@ -10,3 +10,10 @@ - Non-numeric strings, negatives, and overflow-to-infinity values are rejected by schema validation. - `createPrismaWhereSchema` now accepts nested Prisma-style `not` operator objects (e.g. `{ not: { in: [...] } }`) in addition to scalar `not` values. - `createPrismaWhereSchema` and `QueryWhereSchema` now accept top-level `NOT` as either a single filter object or an array of filter objects for Prisma compatibility. + +## 2026-02-19 follow-up (AND/OR + orderBy parity completion) + +- Closed a remaining shape mismatch: `createPrismaWhereSchema` now accepts top-level `AND` / `OR` as `object | object[]` (same flexibility as `NOT`). +- Aligned exported `QueryWhereSchema` to the same logical operand shape (`AND` / `OR` / `NOT` all accept single object or array). +- Unified direction parsing with shared `SortDirectionSchema` so both `getQueryInput` and exported `Query` normalize `orderBy` values (`' DESC ' -> 'desc'`) while still rejecting invalid directions. +- Reused a shared numeric parser (`NumericQueryValue`) for query pagination primitives to keep exported `Query` behavior aligned with `getQueryInput`. diff --git a/util/README.md b/util/README.md index 883ed29..ce12af3 100644 --- a/util/README.md +++ b/util/README.md @@ -8,3 +8,9 @@ Shared schema helpers for protocol routers. - Pagination values are constrained to finite, non-negative integers to avoid invalid/overflow query envelopes. - Prisma-style `where..not` now supports both scalar values and nested operator objects (e.g. `{ not: { in: [...] } }`) for compatibility with richer filter expressions. - Top-level `where.NOT` now accepts either a single filter object or an array of filter objects, matching Prisma-style semantics. + +## 2026-02-19 maintenance update + +- `AND` / `OR` now accept either a single filter object or an array in both recursive where builders and exported `Query` schema. +- `orderBy` directions are normalized with `trim().toLowerCase()` before enum validation. +- Shared numeric query parsing (`skip` / `take` / `limit`) remains finite, non-negative integer constrained for both `getQueryInput` and exported `Query`. diff --git a/util/schema.ts b/util/schema.ts index 61db4e3..b26e71b 100644 --- a/util/schema.ts +++ b/util/schema.ts @@ -87,10 +87,26 @@ const QueryFilterOperators = z.object({ mode: z.enum(['default', 'insensitive']).optional(), }); +const NumericQueryValue = zod.preprocess((value) => { + if (typeof value === 'string' && value.trim() !== '') { + return Number(value); + } + + return value; +}, zod.number().int().nonnegative().finite()); + +const SortDirectionSchema = zod.preprocess((value) => { + if (typeof value === 'string') { + return value.trim().toLowerCase(); + } + + return value; +}, zod.enum(['asc', 'desc'])); + const QueryWhereSchema = z.lazy(() => z.object({ - AND: z.array(QueryWhereSchema).optional(), - OR: z.array(QueryWhereSchema).optional(), + AND: z.union([QueryWhereSchema, z.array(QueryWhereSchema)]).optional(), + OR: z.union([QueryWhereSchema, z.array(QueryWhereSchema)]).optional(), NOT: z.union([QueryWhereSchema, z.array(QueryWhereSchema)]).optional(), id: QueryFilterOperators.optional(), key: QueryFilterOperators.optional(), @@ -101,11 +117,11 @@ const QueryWhereSchema = z.lazy(() => ); export const Query = z.object({ - skip: z.number().default(0).optional(), - take: z.number().default(10).optional(), + skip: NumericQueryValue.default(0).optional(), + take: NumericQueryValue.default(10).optional(), cursor: z.record(z.any()).optional(), where: QueryWhereSchema.optional(), - orderBy: z.record(z.enum(['asc', 'desc'])).optional(), + orderBy: z.record(SortDirectionSchema).optional(), include: z.record(z.boolean()).optional(), select: z.record(z.boolean()).optional(), }); @@ -251,8 +267,8 @@ export const createPrismaWhereSchema = ( const recursiveWhere = zod.lazy(() => createPrismaWhereSchema(modelSchema, depth - 1)); return zod.object({ - AND: zod.array(recursiveWhere).optional(), - OR: zod.array(recursiveWhere).optional(), + AND: zod.union([recursiveWhere, zod.array(recursiveWhere)]).optional(), + OR: zod.union([recursiveWhere, zod.array(recursiveWhere)]).optional(), NOT: zod.union([recursiveWhere, zod.array(recursiveWhere)]).optional(), ...fieldFilters, }); @@ -265,14 +281,6 @@ export const getQueryOutput = (data: T) => { export const getQueryInput = (schema: S, options: { partialData?: boolean } = {}) => { const { partialData = true } = options; - const numericQueryValue = zod.preprocess((value) => { - if (typeof value === 'string' && value.trim() !== '') { - return Number(value); - } - - return value; - }, zod.number().int().nonnegative().finite()); - // Only object schemas get "where" support. const isObjectSchema = schema instanceof zod.ZodObject; @@ -291,15 +299,15 @@ export const getQueryInput = (schema: S, options: { pa data: dataSchema, // keep your query envelope fields - skip: numericQueryValue.default(0).optional(), - limit: numericQueryValue.default(10).optional(), - take: numericQueryValue.optional(), + skip: NumericQueryValue.default(0).optional(), + limit: NumericQueryValue.default(10).optional(), + take: NumericQueryValue.optional(), cursor: zod.record(zod.any()).optional(), // only valid for object schemas where: isObjectSchema ? whereSchema.optional() : zod.undefined().optional(), - orderBy: zod.record(zod.enum(['asc', 'desc'])).optional(), + orderBy: zod.record(SortDirectionSchema).optional(), include: zod.record(zod.boolean()).optional(), select: zod.record(zod.boolean()).optional(), })