Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions test/ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ Current coverage includes:
- Pagination bounds validation (reject negative and infinite/overflow values).
- Prisma-compatible `where.<field>.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.
91 changes: 90 additions & 1 deletion test/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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();
});
});
7 changes: 7 additions & 0 deletions util/ANALYSIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
6 changes: 6 additions & 0 deletions util/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<field>.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`.
46 changes: 27 additions & 19 deletions util/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
});
Expand Down Expand Up @@ -251,8 +267,8 @@ export const createPrismaWhereSchema = <T extends zod.ZodRawShape>(
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,
});
Expand All @@ -265,14 +281,6 @@ export const getQueryOutput = <T extends zod.ZodTypeAny>(data: T) => {
export const getQueryInput = <S extends zod.ZodTypeAny>(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;

Expand All @@ -291,15 +299,15 @@ export const getQueryInput = <S extends zod.ZodTypeAny>(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(),
})
Expand Down