Skip to content

Commit

Permalink
fix(postgres): allow using array operators (e.g. @>) with object ar…
Browse files Browse the repository at this point in the history
…rays

Closes #4973
  • Loading branch information
B4nan committed Nov 28, 2023
1 parent a9074e4 commit ca8795a
Show file tree
Hide file tree
Showing 10 changed files with 87 additions and 15 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/platforms/Platform.ts
Expand Up @@ -311,7 +311,7 @@ export abstract class Platform {
return path;
}

getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean): string {
getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean, value?: unknown): string {
return path.join('.');
}

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/typings.ts
Expand Up @@ -113,9 +113,9 @@ export type OperatorMap<T> = {
$re?: string;
$ilike?: string;
$fulltext?: string;
$overlap?: string[];
$contains?: string[];
$contained?: string[];
$overlap?: string[] | object;
$contains?: string[] | object;
$contained?: string[] | object;
$exists?: boolean;
};

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/utils/QueryHelper.ts
Expand Up @@ -10,7 +10,7 @@ import type {
FilterKey,
FilterQuery,
} from '../typings';
import { ARRAY_OPERATORS, GroupOperator, ReferenceKind } from '../enums';
import { GroupOperator, ReferenceKind } from '../enums';
import type { Platform } from '../platforms';
import type { MetadataStorage } from '../metadata/MetadataStorage';
import { JsonType } from '../types/JsonType';
Expand Down Expand Up @@ -241,7 +241,7 @@ export class QueryHelper {
}, {} as FilterQuery<T>);
}

if (Array.isArray(cond) && !(key && ARRAY_OPERATORS.includes(key))) {
if (Array.isArray(cond) && !(key && Utils.isArrayOperator(key))) {
return (cond as FilterQuery<T>[]).map(v => QueryHelper.processCustomType(prop, v, platform, key, fromQuery)) as unknown as FilterQuery<T>;
}

Expand Down Expand Up @@ -272,7 +272,7 @@ export class QueryHelper {

const operatorObject = Utils.isPlainObject(value) && Object.keys(value).every(k => Utils.isOperator(k));
const type = operatorObject ? typeof Object.values(value)[0] : typeof value;
const k = platform.getSearchJsonPropertyKey(path, type, alias) as FilterKey<T>;
const k = platform.getSearchJsonPropertyKey(path, type, alias, value) as FilterKey<T>;
o[k] = value as any;

return o;
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/utils/Utils.ts
Expand Up @@ -18,7 +18,7 @@ import type {
IMetadataStorage,
Primary,
} from '../typings';
import { GroupOperator, PlainObject, QueryOperator, ReferenceKind } from '../enums';
import { ARRAY_OPERATORS, GroupOperator, PlainObject, QueryOperator, ReferenceKind } from '../enums';
import type { Collection } from '../entity/Collection';
import type { Platform } from '../platforms';
import { helper } from '../entity/wrap';
Expand Down Expand Up @@ -984,6 +984,10 @@ export class Utils {
return key in GroupOperator;
}

static isArrayOperator(key: PropertyKey): boolean {
return ARRAY_OPERATORS.includes(key as string);
}

static hasNestedKey(object: unknown, key: string): boolean {
if (!object) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion packages/knex/src/AbstractSqlDriver.ts
Expand Up @@ -699,7 +699,7 @@ export abstract class AbstractSqlDriver<Connection extends AbstractSqlConnection

const conds = where.map(cond => {
if (Utils.isPlainObject(cond) && Utils.getObjectKeysSize(cond) === 1) {
cond = Object.values(cond)[0];
cond = Object.values(cond)[0] as object;
}

if (pks.length > 1) {
Expand Down
2 changes: 1 addition & 1 deletion packages/knex/src/AbstractSqlPlatform.ts
Expand Up @@ -99,7 +99,7 @@ export abstract class AbstractSqlPlatform extends Platform {
return this.getSearchJsonPropertyKey(path.split('->'), type, aliased);
}

override getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean): string {
override getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean, value?: unknown): string {
const [a, ...b] = path;
const quoteKey = (key: string) => key.match(/^[a-z]\w*$/i) ? key : `"${key}"`;

Expand Down
1 change: 0 additions & 1 deletion packages/knex/src/query/ObjectCriteriaNode.ts
Expand Up @@ -86,7 +86,6 @@ export class ObjectCriteriaNode<T extends object> extends CriteriaNode<T> {
o[`${alias}.${field}`] = payload;
}


return o;
}, {} as Dictionary);
}
Expand Down
8 changes: 7 additions & 1 deletion packages/knex/src/query/ScalarCriteriaNode.ts
@@ -1,4 +1,4 @@
import { ReferenceKind } from '@mikro-orm/core';
import { ReferenceKind, Utils } from '@mikro-orm/core';
import { CriteriaNode } from './CriteriaNode';
import type { IQueryBuilder, ICriteriaNodeProcessOptions } from '../typings';
import { JoinType } from './enums';
Expand All @@ -23,6 +23,12 @@ export class ScalarCriteriaNode<T extends object> extends CriteriaNode<T> {
}
}

if (this.payload && typeof this.payload === 'object' && Object.keys(this.payload).some(key => Utils.isArrayOperator(key))) {
for (const key of Object.keys(this.payload)) {
this.payload[key] = JSON.stringify(this.payload[key]);
}
}

return this.payload;
}

Expand Down
12 changes: 9 additions & 3 deletions packages/postgresql/src/PostgreSqlPlatform.ts
Expand Up @@ -185,7 +185,7 @@ export class PostgreSqlPlatform extends AbstractSqlPlatform {
return 'jsonb';
}

override getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean): string {
override getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean, value?: unknown): string {
const first = path.shift();
const last = path.pop();
const root = this.quoteIdentifier(aliased ? `${ALIAS_REPLACEMENT}.${first}` : first!);
Expand All @@ -194,12 +194,18 @@ export class PostgreSqlPlatform extends AbstractSqlPlatform {
boolean: 'bool',
} as Dictionary;
const cast = (key: string) => raw(type in types ? `(${key})::${types[type]}` : key);
let lastOperator = '->>';

// force `->` for operator payloads with array values
if (Utils.isPlainObject(value) && Object.keys(value).every(key => Utils.isArrayOperator(key) && Array.isArray(value[key]))) {
lastOperator = '->';
}

if (path.length === 0) {
return cast(`${root}->>'${last}'`);
return cast(`${root}${lastOperator}'${last}'`);
}

return cast(`${root}->${path.map(a => this.quoteValue(a)).join('->')}->>'${last}'`);
return cast(`${root}->${path.map(a => this.quoteValue(a)).join('->')}${lastOperator}'${last}'`);
}

override getJsonIndexDefinition(index: IndexDef): string[] {
Expand Down
57 changes: 57 additions & 0 deletions tests/issues/GH4973.test.ts
@@ -0,0 +1,57 @@
import { Collection, Entity, OneToMany, MikroORM, PrimaryKey, Property, ManyToOne } from '@mikro-orm/postgresql';
import { mockLogger } from '../helpers';

@Entity()
export class User {

@PrimaryKey()
id!: number;

@OneToMany(() => Book, book => book.user)
books = new Collection<Book>(this);

}

@Entity()
export class Book {

@PrimaryKey()
id!: number;

@Property({ type: 'jsonb' })
parameters!: BooksParameters;

@ManyToOne(() => User)
user!: User;

}

interface BooksParameters {
seasons: SeasonType[];
}

interface SeasonType {
name: string;
}

let orm: MikroORM;

beforeAll(async () => {
orm = await MikroORM.init({
entities: [User, Book],
dbName: ':memory:',
});
await orm.schema.refreshDatabase();
});

afterAll(() => orm.close(true));

test('GH #4973', async () => {
const mock = mockLogger(orm);
await orm.em.findAll(User, {
where: { books: { parameters: { seasons: { $contains: [{ name: 'summer' }] } } } },
populate: ['books'],
strategy: 'select-in',
});
expect(mock.mock.calls[0][0]).toMatch(`select "u0".* from "user" as "u0" left join "book" as "b1" on "u0"."id" = "b1"."user_id" where "b1"."parameters"->'seasons' @> '[{"name":"summer"}]'`);
});

0 comments on commit ca8795a

Please sign in to comment.