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 Dec 15, 2023
1 parent a7e9a82 commit 6a5a1ef
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 15 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/platforms/Platform.ts
Expand Up @@ -305,7 +305,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 @@ -82,9 +82,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
8 changes: 4 additions & 4 deletions packages/core/src/utils/QueryHelper.ts
@@ -1,7 +1,7 @@
import { Reference } from '../entity/Reference';
import { Utils } from './Utils';
import type { Dictionary, EntityMetadata, EntityProperty, FilterDef, FilterQuery, ObjectQuery } from '../typings';
import { ARRAY_OPERATORS, GroupOperator, ReferenceType } from '../enums';
import { GroupOperator, ReferenceType } from '../enums';
import type { Platform } from '../platforms';
import type { MetadataStorage } from '../metadata/MetadataStorage';
import { JsonType } from '../types/JsonType';
Expand Down Expand Up @@ -244,8 +244,8 @@ export class QueryHelper {
}, {} as ObjectQuery<T>);
}

if (Array.isArray(cond) && !(key && ARRAY_OPERATORS.includes(key))) {
return (cond as ObjectQuery<T>[]).map(v => QueryHelper.processCustomType(prop, v, platform, key, fromQuery)) as unknown as ObjectQuery<T>;
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>;
}

if (platform.isRaw(cond)) {
Expand Down Expand Up @@ -287,7 +287,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);
const k = platform.getSearchJsonPropertyKey(path, type, alias, value);
o[k] = value;

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

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 @@ -551,7 +551,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);
}

getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean): string {
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
10 changes: 9 additions & 1 deletion packages/knex/src/query/ScalarCriteriaNode.ts
@@ -1,4 +1,4 @@
import { ReferenceType } from '@mikro-orm/core';
import { ReferenceType, Utils } from '@mikro-orm/core';
import { CriteriaNode } from './CriteriaNode';
import type { IQueryBuilder } from '../typings';

Expand All @@ -22,6 +22,14 @@ export class ScalarCriteriaNode extends CriteriaNode {
}
}

if (this.payload && typeof this.payload === 'object') {
const keys = Object.keys(this.payload).filter(key => Utils.isArrayOperator(key) && Array.isArray(this.payload[key]));

for (const key of keys) {
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 @@ -162,7 +162,7 @@ export class PostgreSqlPlatform extends AbstractSqlPlatform {
return 'jsonb';
}

getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean): string {
getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean, value?: unknown): string {
const first = path.shift();
const last = path.pop();
const root = aliased ? expr(alias => this.quoteIdentifier(`${alias}.${first}`)) : this.quoteIdentifier(first!);
Expand All @@ -171,12 +171,18 @@ export class PostgreSqlPlatform extends AbstractSqlPlatform {
boolean: 'bool',
};
const cast = (key: string) => 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}'`);
}

quoteIdentifier(id: string, quote = '"'): string {
Expand Down
58 changes: 58 additions & 0 deletions tests/issues/GH4973.test.ts
@@ -0,0 +1,58 @@
import { Collection, Entity, OneToMany, PrimaryKey, Property, ManyToOne, LoadStrategy } from '@mikro-orm/core';
import { MikroORM } 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);
const where = { books: { parameters: { seasons: { $contains: [{ name: 'summer' }] } } } };
await orm.em.find(User, where, {
populate: ['books'],
strategy: LoadStrategy.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 6a5a1ef

Please sign in to comment.