diff --git a/src/packages/pongo/src/core/collection/filters/filters.ts b/src/packages/pongo/src/core/collection/filters/filters.ts index e92566ef..755eb4c4 100644 --- a/src/packages/pongo/src/core/collection/filters/filters.ts +++ b/src/packages/pongo/src/core/collection/filters/filters.ts @@ -10,7 +10,7 @@ const asPlainObjectWithSingleKey = ( !Array.isArray(filter) && Object.keys(filter).length === 1 && key in filter - ? filter + ? { [key]: (filter as Record)[key] } : undefined; export const idFromFilter = ( diff --git a/src/packages/pongo/src/core/typing/operations.ts b/src/packages/pongo/src/core/typing/operations.ts index 39a5fa68..c13645cc 100644 --- a/src/packages/pongo/src/core/typing/operations.ts +++ b/src/packages/pongo/src/core/typing/operations.ts @@ -407,12 +407,6 @@ export declare type Condition = | AlternativeType | PongoFilterOperator>; -export declare type PongoFilter = - | { - [P in keyof WithId]?: Condition[P]>; - } - | HasId; // TODO: & RootFilterOperators>; - export declare interface RootFilterOperators extends Document { $and?: PongoFilter[]; $nor?: PongoFilter[]; @@ -427,6 +421,13 @@ export declare interface RootFilterOperators extends Document { $comment?: string | Document; } +export declare type PongoFilter = + | ({ + [P in keyof WithId]?: Condition[P]>; + } & Pick>, '$and' | '$nor' | '$or'>) + | (HasId & + Pick>, '$and' | '$nor' | '$or'>); + export declare interface PongoFilterOperator< TValue, > extends NonObjectIdLikeDocument { diff --git a/src/packages/pongo/src/e2e/postgresql/pg/postgres.e2e.spec.ts b/src/packages/pongo/src/e2e/postgresql/pg/postgres.e2e.spec.ts index 1cea413d..428542ba 100644 --- a/src/packages/pongo/src/e2e/postgresql/pg/postgres.e2e.spec.ts +++ b/src/packages/pongo/src/e2e/postgresql/pg/postgres.e2e.spec.ts @@ -716,6 +716,110 @@ describe('MongoDB Compatibility Tests', () => { ); }); + it('should find documents with a top-level $or filter', async () => { + const pongoCollection = pongoDb.collection('findWithTopLevelOr'); + const mongoCollection = mongoDb.collection( + 'shimfindWithTopLevelOr', + ); + const docs = [ + { name: 'David', age: 40 }, + { name: 'Eve', age: 45 }, + { name: 'Frank', age: 50 }, + ]; + + await pongoCollection.insertOne(docs[0]!); + await pongoCollection.insertOne(docs[1]!); + await pongoCollection.insertOne(docs[2]!); + + await mongoCollection.insertOne(docs[0]!); + await mongoCollection.insertOne(docs[1]!); + await mongoCollection.insertOne(docs[2]!); + + const pongoDocs = await pongoCollection.find({ + $or: [{ age: 40 }, { age: 50 }], + }); + const mongoDocs = await mongoCollection + .find({ + $or: [{ age: 40 }, { age: 50 }], + }) + .toArray(); + + assert.deepStrictEqual( + pongoDocs.map((d) => ({ name: d.name, age: d.age })), + mongoDocs.map((d) => ({ name: d.name, age: d.age })), + ); + }); + + it('should find documents with nested $and and $or filters', async () => { + const pongoCollection = pongoDb.collection( + 'findWithNestedLogicalOperators', + ); + const mongoCollection = mongoDb.collection( + 'shimfindWithNestedLogicalOperators', + ); + const docs = [ + { name: 'Anita', age: 25 }, + { name: 'Roger', age: 30 }, + { name: 'Cruella', age: 35 }, + ]; + + await pongoCollection.insertOne(docs[0]!); + await pongoCollection.insertOne(docs[1]!); + await pongoCollection.insertOne(docs[2]!); + + await mongoCollection.insertOne(docs[0]!); + await mongoCollection.insertOne(docs[1]!); + await mongoCollection.insertOne(docs[2]!); + + const pongoDocs = await pongoCollection.find({ + $and: [{ age: { $gte: 30 } }, { $or: [{ name: 'Roger' }] }], + }); + const mongoDocs = await mongoCollection + .find({ + $and: [{ age: { $gte: 30 } }, { $or: [{ name: 'Roger' }] }], + }) + .toArray(); + + assert.deepStrictEqual( + pongoDocs.map((d) => ({ name: d.name, age: d.age })), + mongoDocs.map((d) => ({ name: d.name, age: d.age })), + ); + }); + + it('should find documents with a top-level $nor filter', async () => { + const pongoCollection = pongoDb.collection('findWithTopLevelNor'); + const mongoCollection = mongoDb.collection( + 'shimfindWithTopLevelNor', + ); + const docs = [ + { name: 'Anita', age: 25 }, + { name: 'Roger', age: 30 }, + { name: 'Cruella', age: 35 }, + ]; + + await pongoCollection.insertOne(docs[0]!); + await pongoCollection.insertOne(docs[1]!); + await pongoCollection.insertOne(docs[2]!); + + await mongoCollection.insertOne(docs[0]!); + await mongoCollection.insertOne(docs[1]!); + await mongoCollection.insertOne(docs[2]!); + + const pongoDocs = await pongoCollection.find({ + $nor: [{ age: 25 }, { age: 35 }], + }); + const mongoDocs = await mongoCollection + .find({ + $nor: [{ age: 25 }, { age: 35 }], + }) + .toArray(); + + assert.deepStrictEqual( + pongoDocs.map((d) => ({ name: d.name, age: d.age })), + mongoDocs.map((d) => ({ name: d.name, age: d.age })), + ); + }); + it('should find one document with a filter', async () => { const pongoCollection = pongoDb.collection('testCollection'); const mongoCollection = mongoDb.collection('shimtestCollection'); diff --git a/src/packages/pongo/src/e2e/sqlite/sqlite3/sqlite3.e2e.spec.ts b/src/packages/pongo/src/e2e/sqlite/sqlite3/sqlite3.e2e.spec.ts index a023af3e..ba49b530 100644 --- a/src/packages/pongo/src/e2e/sqlite/sqlite3/sqlite3.e2e.spec.ts +++ b/src/packages/pongo/src/e2e/sqlite/sqlite3/sqlite3.e2e.spec.ts @@ -720,6 +720,110 @@ describe('SQLite MongoDB Compatibility Tests', () => { ); }); + it('should find documents with a top-level $or filter', async () => { + const pongoCollection = pongoDb.collection('findWithTopLevelOr'); + const mongoCollection = mongoDb.collection( + 'shimfindWithTopLevelOr', + ); + const docs = [ + { name: 'David', age: 40 }, + { name: 'Eve', age: 45 }, + { name: 'Frank', age: 50 }, + ]; + + await pongoCollection.insertOne(docs[0]!); + await pongoCollection.insertOne(docs[1]!); + await pongoCollection.insertOne(docs[2]!); + + await mongoCollection.insertOne(docs[0]!); + await mongoCollection.insertOne(docs[1]!); + await mongoCollection.insertOne(docs[2]!); + + const pongoDocs = await pongoCollection.find({ + $or: [{ age: 40 }, { age: 50 }], + }); + const mongoDocs = await mongoCollection + .find({ + $or: [{ age: 40 }, { age: 50 }], + }) + .toArray(); + + assert.deepStrictEqual( + pongoDocs.map((d) => ({ name: d.name, age: d.age })), + mongoDocs.map((d) => ({ name: d.name, age: d.age })), + ); + }); + + it('should find documents with nested $and and $or filters', async () => { + const pongoCollection = pongoDb.collection( + 'findWithNestedLogicalOperators', + ); + const mongoCollection = mongoDb.collection( + 'shimfindWithNestedLogicalOperators', + ); + const docs = [ + { name: 'Anita', age: 25 }, + { name: 'Roger', age: 30 }, + { name: 'Cruella', age: 35 }, + ]; + + await pongoCollection.insertOne(docs[0]!); + await pongoCollection.insertOne(docs[1]!); + await pongoCollection.insertOne(docs[2]!); + + await mongoCollection.insertOne(docs[0]!); + await mongoCollection.insertOne(docs[1]!); + await mongoCollection.insertOne(docs[2]!); + + const pongoDocs = await pongoCollection.find({ + $and: [{ age: { $gte: 30 } }, { $or: [{ name: 'Roger' }] }], + }); + const mongoDocs = await mongoCollection + .find({ + $and: [{ age: { $gte: 30 } }, { $or: [{ name: 'Roger' }] }], + }) + .toArray(); + + assert.deepStrictEqual( + pongoDocs.map((d) => ({ name: d.name, age: d.age })), + mongoDocs.map((d) => ({ name: d.name, age: d.age })), + ); + }); + + it('should find documents with a top-level $nor filter', async () => { + const pongoCollection = pongoDb.collection('findWithTopLevelNor'); + const mongoCollection = mongoDb.collection( + 'shimfindWithTopLevelNor', + ); + const docs = [ + { name: 'Anita', age: 25 }, + { name: 'Roger', age: 30 }, + { name: 'Cruella', age: 35 }, + ]; + + await pongoCollection.insertOne(docs[0]!); + await pongoCollection.insertOne(docs[1]!); + await pongoCollection.insertOne(docs[2]!); + + await mongoCollection.insertOne(docs[0]!); + await mongoCollection.insertOne(docs[1]!); + await mongoCollection.insertOne(docs[2]!); + + const pongoDocs = await pongoCollection.find({ + $nor: [{ age: 25 }, { age: 35 }], + }); + const mongoDocs = await mongoCollection + .find({ + $nor: [{ age: 25 }, { age: 35 }], + }) + .toArray(); + + assert.deepStrictEqual( + pongoDocs.map((d) => ({ name: d.name, age: d.age })), + mongoDocs.map((d) => ({ name: d.name, age: d.age })), + ); + }); + it('should find one document with a filter', async () => { const pongoCollection = pongoDb.collection('testCollection'); const mongoCollection = mongoDb.collection('shimtestCollection'); diff --git a/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/filter/index.ts b/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/filter/index.ts index 67e3cb17..3f34b046 100644 --- a/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/filter/index.ts +++ b/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/filter/index.ts @@ -11,20 +11,108 @@ import { handleOperator } from './queryOperators'; export * from './queryOperators'; const AND = 'AND'; +const OR = 'OR'; + +const unsupportedRootOperators = ['$text', '$where', '$comment'] as const; export const constructFilterQuery = ( filter: PongoFilter, serializer: JSONSerializer, +): SQL => { + ensureSupportedRootOperators(filter); + const parts: SQL[] = []; + + const fieldFilterQuery = constructFieldFilterQuery(filter, serializer); + if (!SQL.check.isEmpty(fieldFilterQuery)) { + parts.push(fieldFilterQuery); + } + + const orFilterQuery = constructLogicalFilterQuery(filter.$or, OR, serializer); + if (!SQL.check.isEmpty(orFilterQuery)) { + parts.push(orFilterQuery); + } + + const andFilterQuery = constructLogicalFilterQuery( + filter.$and, + AND, + serializer, + ); + if (!SQL.check.isEmpty(andFilterQuery)) { + parts.push(andFilterQuery); + } + + const norFilterQuery = constructNorFilterQuery(filter.$nor, serializer); + if (!SQL.check.isEmpty(norFilterQuery)) { + parts.push(norFilterQuery); + } + + return SQL.merge(parts, ` ${AND} `); +}; + +const constructFieldFilterQuery = ( + filter: PongoFilter, + serializer: JSONSerializer, ): SQL => SQL.merge( - Object.entries(filter).map(([key, value]) => - isRecord(value) - ? constructComplexFilterQuery(key, value, serializer) - : handleOperator(key, '$eq', value, serializer), + objectEntries(filter).flatMap(([key, value]) => + isLogicalRootOperator(key) + ? [] + : [ + isRecord(value) + ? constructComplexFilterQuery(key, value, serializer) + : handleOperator(key, QueryOperators.$eq, value, serializer), + ], ), ` ${AND} `, ); +const constructLogicalFilterQuery = ( + filters: PongoFilter[] | undefined, + joinOperator: typeof AND | typeof OR, + serializer: JSONSerializer, +): SQL => { + if (!filters?.length) { + return SQL.EMPTY; + } + + const subFilterQueries = filters.reduce((queries, filter) => { + const query = constructFilterQuery(filter, serializer); + if (!SQL.check.isEmpty(query)) { + queries.push(query); + } + + return queries; + }, []); + + if (subFilterQueries.length === 0) { + return SQL.EMPTY; + } + + if (subFilterQueries.length === 1) { + return wrapFilterQuery(subFilterQueries[0]!); + } + + return SQL`(${SQL.merge(subFilterQueries.map(wrapFilterQuery), ` ${joinOperator} `)})`; +}; + +const constructNorFilterQuery = ( + filters: PongoFilter[] | undefined, + serializer: JSONSerializer, +): SQL => { + if (!filters?.length) { + return SQL.EMPTY; + } + + const logicalFilterQuery = constructLogicalFilterQuery( + filters, + OR, + serializer, + ); + return SQL.check.isEmpty(logicalFilterQuery) + ? SQL.EMPTY + : SQL`NOT ${logicalFilterQuery}`; +}; + const constructComplexFilterQuery = ( key: string, value: Record, @@ -47,5 +135,18 @@ const constructComplexFilterQuery = ( ); }; +const wrapFilterQuery = (filterQuery: SQL): SQL => SQL`(${filterQuery})`; + +const ensureSupportedRootOperators = (filter: object): void => { + for (const operator of unsupportedRootOperators) { + if (operator in filter) { + throw new Error(`Unsupported root operator: ${operator}`); + } + } +}; + +const isLogicalRootOperator = (key: string): key is '$and' | '$nor' | '$or' => + key === '$and' || key === '$nor' || key === '$or'; + const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); diff --git a/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/sqlBuilder.unit.spec.ts b/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/sqlBuilder.unit.spec.ts index 776ba9c8..8f6c69b6 100644 --- a/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/sqlBuilder.unit.spec.ts +++ b/src/packages/pongo/src/storage/postgresql/core/sqlBuilder/sqlBuilder.unit.spec.ts @@ -128,3 +128,53 @@ describe('find() sort option', () => { assert.ok(!sql.includes('data ->'), `got: ${sql}`); }); }); + +describe('find() logical operators', () => { + const builder = postgresSQLBuilder('users', JSONSerializer); + + it('supports top-level $or', () => { + const query = builder.find<{ flag: boolean }>({ + $or: [{ flag: true }, { flag: false }], + }); + const { query: sql } = SQL.format(query, pgFormatter); + + assert.ok(sql.includes(' OR '), `got: ${sql}`); + assert.ok(!sql.includes('$.$or'), `got: ${sql}`); + assert.ok(!sql.includes('1 = 0'), `got: ${sql}`); + assert.ok(!sql.includes('1 = 1'), `got: ${sql}`); + }); + + it('ANDs normal fields with $or blocks', () => { + const query = builder.find<{ flag: boolean; status: string }>({ + status: 'active', + $or: [{ flag: true }, { flag: false }], + }); + const { query: sql } = SQL.format(query, pgFormatter); + + assert.ok(sql.includes(' AND '), `got: ${sql}`); + assert.ok(sql.includes(' OR '), `got: ${sql}`); + }); + + it('supports nested logical operators', () => { + const query = builder.find<{ flag: boolean; status: string }>({ + $and: [{ status: 'active' }, { $or: [{ flag: true }, { flag: false }] }], + }); + const { query: sql } = SQL.format(query, pgFormatter); + + assert.ok(sql.includes(' AND '), `got: ${sql}`); + assert.ok(sql.includes(' OR '), `got: ${sql}`); + assert.ok(!sql.includes('1 = 0'), `got: ${sql}`); + assert.ok(!sql.includes('1 = 1'), `got: ${sql}`); + }); + + it('throws for unsupported root operators instead of treating them as fields', () => { + const unsupportedFilter = { + $text: { $search: 'active' }, + } as unknown as Parameters[0]; + + assert.throws( + () => builder.find(unsupportedFilter), + /Unsupported root operator: \$text/, + ); + }); +}); diff --git a/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/filter/index.ts b/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/filter/index.ts index 67e3cb17..3f34b046 100644 --- a/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/filter/index.ts +++ b/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/filter/index.ts @@ -11,20 +11,108 @@ import { handleOperator } from './queryOperators'; export * from './queryOperators'; const AND = 'AND'; +const OR = 'OR'; + +const unsupportedRootOperators = ['$text', '$where', '$comment'] as const; export const constructFilterQuery = ( filter: PongoFilter, serializer: JSONSerializer, +): SQL => { + ensureSupportedRootOperators(filter); + const parts: SQL[] = []; + + const fieldFilterQuery = constructFieldFilterQuery(filter, serializer); + if (!SQL.check.isEmpty(fieldFilterQuery)) { + parts.push(fieldFilterQuery); + } + + const orFilterQuery = constructLogicalFilterQuery(filter.$or, OR, serializer); + if (!SQL.check.isEmpty(orFilterQuery)) { + parts.push(orFilterQuery); + } + + const andFilterQuery = constructLogicalFilterQuery( + filter.$and, + AND, + serializer, + ); + if (!SQL.check.isEmpty(andFilterQuery)) { + parts.push(andFilterQuery); + } + + const norFilterQuery = constructNorFilterQuery(filter.$nor, serializer); + if (!SQL.check.isEmpty(norFilterQuery)) { + parts.push(norFilterQuery); + } + + return SQL.merge(parts, ` ${AND} `); +}; + +const constructFieldFilterQuery = ( + filter: PongoFilter, + serializer: JSONSerializer, ): SQL => SQL.merge( - Object.entries(filter).map(([key, value]) => - isRecord(value) - ? constructComplexFilterQuery(key, value, serializer) - : handleOperator(key, '$eq', value, serializer), + objectEntries(filter).flatMap(([key, value]) => + isLogicalRootOperator(key) + ? [] + : [ + isRecord(value) + ? constructComplexFilterQuery(key, value, serializer) + : handleOperator(key, QueryOperators.$eq, value, serializer), + ], ), ` ${AND} `, ); +const constructLogicalFilterQuery = ( + filters: PongoFilter[] | undefined, + joinOperator: typeof AND | typeof OR, + serializer: JSONSerializer, +): SQL => { + if (!filters?.length) { + return SQL.EMPTY; + } + + const subFilterQueries = filters.reduce((queries, filter) => { + const query = constructFilterQuery(filter, serializer); + if (!SQL.check.isEmpty(query)) { + queries.push(query); + } + + return queries; + }, []); + + if (subFilterQueries.length === 0) { + return SQL.EMPTY; + } + + if (subFilterQueries.length === 1) { + return wrapFilterQuery(subFilterQueries[0]!); + } + + return SQL`(${SQL.merge(subFilterQueries.map(wrapFilterQuery), ` ${joinOperator} `)})`; +}; + +const constructNorFilterQuery = ( + filters: PongoFilter[] | undefined, + serializer: JSONSerializer, +): SQL => { + if (!filters?.length) { + return SQL.EMPTY; + } + + const logicalFilterQuery = constructLogicalFilterQuery( + filters, + OR, + serializer, + ); + return SQL.check.isEmpty(logicalFilterQuery) + ? SQL.EMPTY + : SQL`NOT ${logicalFilterQuery}`; +}; + const constructComplexFilterQuery = ( key: string, value: Record, @@ -47,5 +135,18 @@ const constructComplexFilterQuery = ( ); }; +const wrapFilterQuery = (filterQuery: SQL): SQL => SQL`(${filterQuery})`; + +const ensureSupportedRootOperators = (filter: object): void => { + for (const operator of unsupportedRootOperators) { + if (operator in filter) { + throw new Error(`Unsupported root operator: ${operator}`); + } + } +}; + +const isLogicalRootOperator = (key: string): key is '$and' | '$nor' | '$or' => + key === '$and' || key === '$nor' || key === '$or'; + const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); diff --git a/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/sqlBuilder.unit.spec.ts b/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/sqlBuilder.unit.spec.ts index 50c9cb18..d187e55c 100644 --- a/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/sqlBuilder.unit.spec.ts +++ b/src/packages/pongo/src/storage/sqlite/core/sqlBuilder/sqlBuilder.unit.spec.ts @@ -1,5 +1,5 @@ import { JSONSerializer, SQL } from '@event-driven-io/dumbo'; -import { sqliteFormatter } from '@event-driven-io/dumbo/sqlite3'; +import { sqliteFormatter } from '@event-driven-io/dumbo/sqlite'; import { randomUUID } from 'crypto'; import assert from 'node:assert/strict'; import { describe, it } from 'vitest'; @@ -218,4 +218,55 @@ describe('sqliteSQLBuilder', () => { assert.ok(query.includes('WHERE')); }); }); + + describe('logical operators', () => { + it('supports top-level $or', () => { + const result = builder.find<{ flag: boolean }>({ + $or: [{ flag: true }, { flag: false }], + }); + const { query } = SQL.format(result, sqliteFormatter); + + assert.ok(query.includes(' OR '), `got: ${query}`); + assert.ok(!query.includes('$.$or'), `got: ${query}`); + assert.ok(!query.includes('1 = 0'), `got: ${query}`); + assert.ok(!query.includes('1 = 1'), `got: ${query}`); + }); + + it('ANDs normal fields with $or blocks', () => { + const result = builder.find<{ flag: boolean; status: string }>({ + status: 'active', + $or: [{ flag: true }, { flag: false }], + }); + const { query } = SQL.format(result, sqliteFormatter); + + assert.ok(query.includes(' AND '), `got: ${query}`); + assert.ok(query.includes(' OR '), `got: ${query}`); + }); + + it('supports nested logical operators', () => { + const result = builder.find<{ flag: boolean; status: string }>({ + $and: [ + { status: 'active' }, + { $or: [{ flag: true }, { flag: false }] }, + ], + }); + const { query } = SQL.format(result, sqliteFormatter); + + assert.ok(query.includes(' AND '), `got: ${query}`); + assert.ok(query.includes(' OR '), `got: ${query}`); + assert.ok(!query.includes('1 = 0'), `got: ${query}`); + assert.ok(!query.includes('1 = 1'), `got: ${query}`); + }); + + it('throws for unsupported root operators instead of treating them as fields', () => { + const unsupportedFilter = { + $text: { $search: 'active' }, + } as unknown as Parameters[0]; + + assert.throws( + () => builder.find(unsupportedFilter), + /Unsupported root operator: \$text/, + ); + }); + }); });