diff --git a/packages/accel-record-core/src/errors.ts b/packages/accel-record-core/src/errors.ts index 774637c9..69b90bca 100644 --- a/packages/accel-record-core/src/errors.ts +++ b/packages/accel-record-core/src/errors.ts @@ -1 +1,2 @@ export class RecordNotFound extends Error {} +export class AttributeNotFound extends Error {} diff --git a/packages/accel-record-core/src/index.ts b/packages/accel-record-core/src/index.ts index 63d47479..58875892 100644 --- a/packages/accel-record-core/src/index.ts +++ b/packages/accel-record-core/src/index.ts @@ -10,6 +10,7 @@ import { ModelMeta } from "./meta.js"; import { Dirty } from "./model/dirty.js"; import { Import } from "./model/import.js"; import { Naming } from "./model/naming.js"; +import { Searchable } from "./model/search.js"; import { Serialization } from "./model/serialization.js"; import { Validations } from "./model/validations.js"; import { Persistence } from "./persistence.js"; @@ -71,6 +72,7 @@ export class Model extends Mix( Naming, Persistence, Query, + Searchable, Serialization, Transaction, Validations diff --git a/packages/accel-record-core/src/model/search.ts b/packages/accel-record-core/src/model/search.ts new file mode 100644 index 00000000..215abbcd --- /dev/null +++ b/packages/accel-record-core/src/model/search.ts @@ -0,0 +1,88 @@ +import { Model } from "../index.js"; +import { Search } from "../search/index.js"; + +/** + * Represents a class for handling search parameters. + * + * This class is intended to be inherited by the Model class. + */ +export class Searchable { + /** + * Creates a new search instance. + * @param params - The search parameters. + * @returns A new search instance. + * @example + * ```ts + * const search = User.search({ name_eq: "foo" }); + * const users = search.result(); + * ``` + * @details + * The search parameters are used to filter the results of the search. \ + * The search parameters are specified as key-value pairs. \ + * The key is the name of the attribute to search for. + * The key can include associations. \ + * The value is the value to search for. + * + * The search parameters can include the following: + * - `*_eq` - The attribute is equal to the value. + * - `*_cont` - The attribute contains the value. + * - `*_start` - The attribute starts with the value. + * - `*_end` - The attribute ends with the value. + * - `*_matches` - The attribute matches the value (Use the `LIKE` clause). + * - `*_lt` - The attribute is less than the value. + * - `*_lte` - The attribute is less than or equal to the value. + * - `*_gt` - The attribute is greater than the value. + * - `*_gte` - The attribute is greater than or equal to the value. + * - `*_in` - The attribute is in the list of values. + * - `*_true` - The attribute is true. + * - `*_false` - The attribute is false. + * - `*_null` - The attribute is null. + * - `*_blank` - The attribute is blank. + * - `*_present` - The attribute is present. + * + * The search parameters can also include the following modifiers: + * - `not` (`does_not`) - The value must not match. + * - `all` - All of the values must match. + * - `any` - Any of the values must match. + * + * The search parameters can also include the following logical operators: + * - `and` - The values must match all of the attributes. + * - `or` - The values must match any of the attributes. + * @example + * ```ts + * const search = User.search({ + * profile_bio_cont: "foo", // with association + * age_not_null: 1, // with `not` modifier + * email_or_name_cont_any: ["bar", "baz"], // with `or` operator and `any` modifier + * }); + * const users = search.result(); + * ``` + * @details + * The keys of the search parameters can include the names of the searchable scopes defined in the `searchableScopes` array. \ + * The values of the search parameters specify the values to search for. + * @example + * ```ts + * class UserModel extends ApplicationRecord { + * static bio_cont(value: string) { + * return this.joins("profile").where({ + * profile: { bio: { 'contains': value } }, + * }); + * } + * static searchableScopes = ["bio_cont"]; + * } + * ``` + * // The `bio_cont` scope can be used in the search parameters. + * ```ts + * const search = User.search({ bio_cont: "foo" }); + * const users = search.result(); + * ``` + */ + static search(this: T, params: Record) { + return new Search(this, params); + } + + /** + * An array of searchable scopes used in the search() method. + */ + static searchableScopes: string[] = []; +} diff --git a/packages/accel-record-core/src/relation/base.ts b/packages/accel-record-core/src/relation/base.ts index e2ff1db9..25b5e99c 100644 --- a/packages/accel-record-core/src/relation/base.ts +++ b/packages/accel-record-core/src/relation/base.ts @@ -78,6 +78,17 @@ export class RelationBase { } }); } + for (const where of this.options.orWhereNots) { + q = q.orWhere(function (this: any) { + for (const w of where) { + if (Array.isArray(w)) { + this.whereNot(...w); + } else { + this.whereNot(w); + } + } + }); + } if (this.options.limit) q = q.limit(this.options.limit); if (this.options.offset) q = q.offset(this.options.offset); for (const [column, direction] of this.options.orders ?? []) { diff --git a/packages/accel-record-core/src/relation/index.ts b/packages/accel-record-core/src/relation/index.ts index 1b8ce828..fae9ec90 100644 --- a/packages/accel-record-core/src/relation/index.ts +++ b/packages/accel-record-core/src/relation/index.ts @@ -6,9 +6,10 @@ import { Association } from "./association.js"; import { RelationBase } from "./base.js"; import { Batches } from "./batches.js"; import { Calculations } from "./calculations.js"; -import { getDefaultOptions, Options } from "./options.js"; import { Merge } from "./merge.js"; +import { getDefaultOptions, Options } from "./options.js"; import { Query } from "./query.js"; +import { Searchable } from "./search.js"; import { Where } from "./where.js"; export { Options } from "./options.js"; @@ -24,6 +25,7 @@ export class Relation extends Mix( Merge, Query, RelationBase, + Searchable, Where ) { protected counter = 0; diff --git a/packages/accel-record-core/src/relation/options.ts b/packages/accel-record-core/src/relation/options.ts index 1d2ce97e..4a19f583 100644 --- a/packages/accel-record-core/src/relation/options.ts +++ b/packages/accel-record-core/src/relation/options.ts @@ -8,6 +8,7 @@ export type Options = { whereNots: any[]; whereRaws: [string, any[]][]; orWheres: any[]; + orWhereNots: any[]; orders: [string, "asc" | "desc"][]; offset: number | undefined; limit: number | undefined; @@ -23,6 +24,7 @@ export const getDefaultOptions = (): Options => ({ whereNots: [], whereRaws: [], orWheres: [], + orWhereNots: [], orders: [], offset: undefined, limit: undefined, diff --git a/packages/accel-record-core/src/relation/search.ts b/packages/accel-record-core/src/relation/search.ts new file mode 100644 index 00000000..9aaec573 --- /dev/null +++ b/packages/accel-record-core/src/relation/search.ts @@ -0,0 +1,87 @@ +import { ModelMeta } from "../meta.js"; +import { Search } from "../search/index.js"; +import { Relation } from "./index.js"; + +/** + * Provides the search methods for relations. + * + * This class is intended to be inherited by the Relation class. + */ +export class Searchable { + /** + * Creates a new search instance. + * @param params - The search parameters. + * @returns A new search instance. + * @example + * ```ts + * const search = User.search({ name_eq: "foo" }); + * const users = search.result(); + * ``` + * @details + * The search parameters are used to filter the results of the search. \ + * The search parameters are specified as key-value pairs. \ + * The key is the name of the attribute to search for. + * The key can include associations. \ + * The value is the value to search for. + * + * The search parameters can include the following: + * - `*_eq` - The attribute is equal to the value. + * - `*_cont` - The attribute contains the value. + * - `*_start` - The attribute starts with the value. + * - `*_end` - The attribute ends with the value. + * - `*_matches` - The attribute matches the value (Use the `LIKE` clause). + * - `*_lt` - The attribute is less than the value. + * - `*_lte` - The attribute is less than or equal to the value. + * - `*_gt` - The attribute is greater than the value. + * - `*_gte` - The attribute is greater than or equal to the value. + * - `*_in` - The attribute is in the list of values. + * - `*_true` - The attribute is true. + * - `*_false` - The attribute is false. + * - `*_null` - The attribute is null. + * - `*_blank` - The attribute is blank. + * - `*_present` - The attribute is present. + * + * The search parameters can also include the following modifiers: + * - `not` (`does_not`) - The value must not match. + * - `all` - All of the values must match. + * - `any` - Any of the values must match. + * + * The search parameters can also include the following logical operators: + * - `and` - The values must match all of the attributes. + * - `or` - The values must match any of the attributes. + * @example + * ```ts + * const search = User.search({ + * profile_bio_cont: "foo", // with association + * age_not_null: 1, // with `not` modifier + * email_or_name_cont_any: ["bar", "baz"], // with `or` operator and `any` modifier + * }); + * const users = search.result(); + * ``` + * @details + * The keys of the search parameters can include the names of the searchable scopes defined in the `searchableScopes` array. \ + * The values of the search parameters specify the values to search for. + * @example + * ```ts + * class UserModel extends ApplicationRecord { + * static bio_cont(value: string) { + * return this.joins("profile").where({ + * profile: { bio: { 'contains': value } }, + * }); + * } + * static searchableScopes = ["bio_cont"]; + * } + * ``` + * // The `bio_cont` scope can be used in the search parameters. + * ```ts + * const search = User.search({ bio_cont: "foo" }); + * const users = search.result(); + * ``` + */ + search( + this: Relation, + params: Record + ) { + return new Search(this.model, params, this); + } +} diff --git a/packages/accel-record-core/src/relation/where.ts b/packages/accel-record-core/src/relation/where.ts index 5883a34e..ec79398f 100644 --- a/packages/accel-record-core/src/relation/where.ts +++ b/packages/accel-record-core/src/relation/where.ts @@ -1,3 +1,4 @@ +import { AttributeNotFound } from "../errors.js"; import { Model, Models, Relation } from "../index.js"; import { ModelMeta } from "../meta.js"; import { Relations } from "./index.js"; @@ -50,7 +51,7 @@ export class Where { newOptions["wheres"].push(where); } } else { - throw new Error(`Attribute not found: ${key}`); + throw new AttributeNotFound(`Attribute not found: ${key}`); } } else if (Array.isArray(input[key])) { newOptions["wheres"].push([col, "in", input[key]]); @@ -203,6 +204,7 @@ export class Where { : new Relation(this.model, {}).where(relationOrInput); const newOptions = JSON.parse(JSON.stringify(this.options)); newOptions["orWheres"].push(relation.options.wheres); + newOptions["orWhereNots"].push(relation.options.whereNots); return new Relation(this.model, newOptions); } } diff --git a/packages/accel-record-core/src/search/condition.ts b/packages/accel-record-core/src/search/condition.ts new file mode 100644 index 00000000..4d545536 --- /dev/null +++ b/packages/accel-record-core/src/search/condition.ts @@ -0,0 +1,34 @@ +export const getCondition = (predicate: string, value: any) => { + switch (predicate) { + case "eq": + return value; + case "cont": + return { contains: value }; + case "start": + return { startsWith: value }; + case "end": + return { endsWith: value }; + case "matches": + case "match": + return { like: value }; + case "lt": + return { "<": value }; + case "lte": + return { "<=": value }; + case "gt": + return { ">": value }; + case "gte": + return { ">=": value }; + case "in": + return { in: value }; + case "true": + return true; + case "false": + return false; + case "null": + return null; + default: + // unknown predicate + return value; + } +}; diff --git a/packages/accel-record-core/src/search/index.ts b/packages/accel-record-core/src/search/index.ts new file mode 100644 index 00000000..813ac3d3 --- /dev/null +++ b/packages/accel-record-core/src/search/index.ts @@ -0,0 +1,135 @@ +import { AttributeNotFound } from "../errors.js"; +import type { Model } from "../index.js"; +import type { Relation } from "../relation/index.js"; +import { getCondition } from "./condition.js"; +import { Predicate, Query } from "./query.js"; + +export class Search { + constructor( + protected model: typeof Model, + protected params: Record, + protected relation: Relation | undefined = undefined + ) {} + + /** + * Retrieves the search result based on the specified parameters. + */ + result() { + let relation = this.relation ?? this.model.all(); + for (const [key, value] of Object.entries(this.params)) { + try { + relation = this.updateRelation(relation, key, value); + } catch (e) { + if (e instanceof AttributeNotFound) { + // Ignore the error + } else throw e; + } + } + return relation; + } + + private updateRelation( + relation: Relation, + key: string, + value: any + ) { + if (this.model.searchableScopes.includes(key)) { + return relation.merge((this.model as any)[key](value)); + } + const q = new Query(key, value); + if (!q.isValid) return relation; + + const { predicate, orList } = q; + if (orList.length > 1) { + let tmp = orList.reduce((acc: Relation | undefined, attr) => { + const r = this.affectQuery(this.model.all(), attr, predicate, value); + return acc?.or(r) ?? r; + }, undefined); + return relation.merge(tmp!); + } + for (const attr of q.attributes) { + relation = this.affectQuery(relation, attr, predicate, value); + } + return relation; + } + + private affectQuery( + relation: Relation, + attrStr: string, + predicate: Predicate, + value: any + ) { + switch (predicate.name) { + case "blank": { + const w1 = this.buildWhere(this.model, attrStr, predicate, ""); + const w2 = this.buildWhere(this.model, attrStr, predicate, null); + return relation.joins(w1.joins).where(w1.where).or(w2.where); + } + case "present": { + const w1 = this.buildWhere(this.model, attrStr, predicate, ""); + const w2 = this.buildWhere(this.model, attrStr, predicate, null); + return relation.joins(w1.joins).whereNot(w1.where).whereNot(w2.where); + } + default: + return this.affectDefaultQuery(relation, attrStr, predicate, value); + } + } + + private affectDefaultQuery( + relation: Relation, + attrStr: string, + predicate: Predicate, + value: any + ) { + const method = predicate.not ? "whereNot" : "where"; + const values = [value].flat(); + switch (predicate.type) { + case "all": { + let ret = relation; + for (const v of values) { + const w = this.buildWhere(this.model, attrStr, predicate, v); + ret = ret.joins(w.joins)[method](w.where); + } + return ret; + } + case "any": { + let tmp = values.reduce((acc: Relation | undefined, v) => { + const w = this.buildWhere(this.model, attrStr, predicate, v); + const r = this.model[method](w.where); + return acc?.or(r) ?? r.joins(w.joins); + }, undefined); + return relation.merge(tmp); + } + default: { + const w = this.buildWhere(this.model, attrStr, predicate, value); + return relation.joins(w.joins)[method](w.where); + } + } + } + + private buildWhere( + model: typeof Model, + attrStr: string, + predicate: Predicate, + value: any + ): { where: object; joins: object } { + const field = model.findField(attrStr); + if (!field) { + for (const [key, association] of Object.entries(model.associations)) { + if (attrStr.startsWith(`${key}_`)) { + const nextAttr = attrStr.substring(key.length + 1); + const { model: nextModel } = association; + const next = this.buildWhere(nextModel, nextAttr, predicate, value); + return { + where: { [key]: next.where }, + joins: { [key]: next.joins }, + }; + } + } + } + return { + where: { [attrStr]: getCondition(predicate.name, value) }, + joins: {}, + }; + } +} diff --git a/packages/accel-record-core/src/search/query.ts b/packages/accel-record-core/src/search/query.ts new file mode 100644 index 00000000..9b2e7b8c --- /dev/null +++ b/packages/accel-record-core/src/search/query.ts @@ -0,0 +1,54 @@ +export class Predicate { + name: string = ""; + not = false; + type: "all" | "any" | undefined; + + constructor(protected key: string) { + let name = key; + if (name.startsWith("not_")) { + name = name.substring(4); + this.not = true; + } + if (name.startsWith("does_not_")) { + name = name.substring(9); + this.not = true; + } + if (name.endsWith("_all")) { + name = name.substring(0, name.length - 4); + this.type = "all"; + } else if (name.endsWith("_any")) { + name = name.substring(0, name.length - 4); + this.type = "any"; + } + this.name = name; + } +} + +export class Query { + name: string = ""; + predicate: Predicate = new Predicate(""); + + constructor( + protected key: string, + protected value: any + ) { + const parsed = key.match( + /^(.+?)_(((does_)?not_)?(eq|in|cont|start|end|null|match(es)?|lt|lte|gt|gte|true|false|blank|present)(_all|_any)?)$/ + ); + if (!parsed) return; + this.name = parsed[1]; + this.predicate = new Predicate(parsed[2]); + } + + get isValid() { + return this.name !== "" && this.predicate.name !== ""; + } + + get orList() { + return this.name.split("_or_"); + } + + get attributes() { + return this.name.split("_and_"); + } +} diff --git a/packages/accel-record/README-ja.md b/packages/accel-record/README-ja.md index 41e08081..5c027556 100644 --- a/packages/accel-record/README-ja.md +++ b/packages/accel-record/README-ja.md @@ -28,8 +28,9 @@ MySQL, PostgreSQL, SQLiteでの利用が可能です。 - [Jsonフィールドの型](#jsonフィールドの型) - [関連付け](#関連付け) - [クエリインターフェース](#クエリインターフェース) -- [テスト](#テスト) - [スコープ](#スコープ) +- [Flexible Search](#flexible-search) +- [テスト](#テスト) - [バリデーション](#バリデーション) - [コールバック](#コールバック) - [Serialization](#serialization) @@ -780,6 +781,57 @@ import { User } from "./models/index.js"; User.johns().adults().count(); // => 1 ``` +## Flexible Search + +`.search()`メソッドを使うと、オブジェクトベースの柔軟な検索が可能です。 +(インターフェースは Ransack gem を参考にしています) + +検索パラメータは、フィールド名と検索条件を組み合わせた文字列をキー、検索用の値をバリューとして持つオブジェクトで指定します。 +キーには関連付けを含めることができます。 +検索条件には、`eq`, `cont`, `matches`, `lt`, `gte`, `in`, `null` などが利用可能です。 +その他に `not`, `or`, `and`, `any`, `all` などの修飾子も用意されています。 +詳細はsearch()メソッドのドキュメントを参照してください。 + +```ts +import { User } from "./models/index.js"; + +const search = User.search({ + name_eq: "John", // name が "John" に等しい + age_not_null: 1, // age が null でない + profile_bio_cont: "foo", // 関連である profile の bio が "foo" を含む + email_or_name_cont_any: ["bar", "baz"], // email または name が "bar" または "baz" を含む +}); +const users = search.result(); +``` + +また、検索パラメータのキーには`searchableScopes`配列で定義された検索可能なスコープの名前を含めることができます。 + +例えば以下のように定義された`bio_cont`スコープは検索パラメータで使用することができます。 + +```ts +// src/models/user.ts + +import { scope } from "accel-record"; +import { ApplicationRecord } from "./applicationRecord.js"; + +class UserModel extends ApplicationRecord { + @scope + static bio_cont(value: string) { + return this.joins("profile").where({ + profile: { bio: { contains: value } }, + }); + } + static searchableScopes = ["bio_cont"]; +} +``` + +```ts +import { User } from "./models/index.js"; + +const search = User.search({ bio_cont: "foo" }); // profile の bio が "foo" を含む +const users = search.result(); +``` + ## テスト ### Vitestを利用したテスト @@ -1290,6 +1342,5 @@ user.update({ age: undefined }); ## 今後予定されている機能追加 - [accel-record-core] 複合IDの対応 -- [accel-record-core] クエリインターフェースの拡充 関連: [Accel Record Roadmap](https://github.com/koyopro/accella/issues/1) diff --git a/packages/accel-record/README.md b/packages/accel-record/README.md index c924e606..d18e53e0 100644 --- a/packages/accel-record/README.md +++ b/packages/accel-record/README.md @@ -27,8 +27,9 @@ It can be used with MySQL, PostgreSQL, and SQLite. - [Type of Json Field](#type-of-json-field) - [Associations](#associations) - [Query Interface](#query-interface) -- [Testing](#testing) - [Scopes](#scopes) +- [Flexible Search](#flexible-search) +- [Testing](#testing) - [Validation](#validation) - [Callbacks](#callbacks) - [Serialization](#serialization) @@ -779,6 +780,57 @@ import { User } from "./models/index.js"; User.johns().adults().count(); // => 1 ``` +## Flexible Search + +Using the `.search()` method, you can perform object-based flexible searches. +(The interface is inspired by the Ransack gem.) + +Search parameters are specified as an object with keys representing the field name and search condition combination strings, and values representing the search values. +You can include associations in the keys. +The search conditions include `eq`, `cont`, `matches`, `lt`, `gte`, `in`, `null`, and more. +In addition, modifiers such as `not`, `or`, `and`, `any`, `all` are also available. +Please refer to the documentation of the search() method for more details. + +```ts +import { User } from "./models/index.js"; + +const search = User.search({ + name_eq: "John", // name equals "John" + age_not_null: 1, // age is not null + profile_bio_cont: "foo", // related profile's bio contains "foo" + email_or_name_cont_any: ["bar", "baz"], // email or name contains "bar" or "baz" +}); +const users = search.result(); +``` + +Additionally, you can include the names of searchable scopes defined in the `searchableScopes` array as keys in the search parameters. + +For example, the `bio_cont` scope defined as follows can be used in the search parameters: + +```ts +// src/models/user.ts + +import { scope } from "accel-record"; +import { ApplicationRecord } from "./applicationRecord.js"; + +class UserModel extends ApplicationRecord { + @scope + static bio_cont(value: string) { + return this.joins("profile").where({ + profile: { bio: { contains: value } }, + }); + } + static searchableScopes = ["bio_cont"]; +} +``` + +```ts +import { User } from "./models/index.js"; + +const search = User.search({ bio_cont: "foo" }); // profile's bio contains "foo" +const users = search.result(); +``` + ## Testing ### Testing with Vitest @@ -1289,6 +1341,5 @@ user.update({ age: undefined }); ## Future Planned Features - [accel-record-core] Support for Composite IDs -- [accel-record-core] Expansion of Query Interface Related: [Accel Record Roadmap](https://github.com/koyopro/accella/issues/1) diff --git a/tests/models/model/search.test.ts b/tests/models/model/search.test.ts new file mode 100644 index 00000000..939faa6f --- /dev/null +++ b/tests/models/model/search.test.ts @@ -0,0 +1,117 @@ +import { Profile, User } from ".."; +import { $post } from "../../factories/post"; +import { $postTag } from "../../factories/postTag"; +import { $Profile } from "../../factories/profile"; +import { $user } from "../../factories/user"; + +test(".search()", () => { + $user.create({ id: 1, age: 10, email: "foo@example.com", name: "foo" }); + $user.create({ id: 2, age: 20, email: "cake@foo.com", name: "bar" }); + $user.create({ id: 3, age: 30, email: "choco@example.com", name: "foobar" }); + $user.create({ id: 4, email: "juice@example.com", name: "baz" }); + + const subject = (params: any): number => User.search(params).result().count(); + + expect(subject({})).toEqual(4); + expect(subject({ name_eq: "foo" })).toEqual(1); + expect(subject({ name_cont: "bar" })).toEqual(2); + expect(subject({ email_or_name_cont: "foo" })).toEqual(3); + expect(subject({ email_and_name_cont: "foo" })).toEqual(1); + expect(subject({ name_start: "bar" })).toEqual(1); + expect(subject({ name_end: "bar" })).toEqual(2); + expect(subject({ name_matches: "ba%" })).toEqual(2); + expect(subject({ name_in: ["foo", "bar", "baz"] })).toEqual(3); + + expect(subject({ name_not_eq: "foo" })).toEqual(3); + expect(subject({ name_not_cont: "bar" })).toEqual(2); + expect(subject({ name_not_start: "bar" })).toEqual(3); + expect(subject({ name_not_end: "bar" })).toEqual(2); + expect(subject({ name_not_in: ["foo", "bar", "baz"] })).toEqual(1); + expect(subject({ name_does_not_match: "ba%" })).toEqual(2); + + expect(subject({ name_cont_all: ["bar", "foo"] })).toEqual(1); + expect(subject({ name_end_all: ["r", "ar", "obar"] })).toEqual(1); + expect(subject({ name_matches_all: ["foo%", "%bar"] })).toEqual(1); + + expect(subject({ name_cont_any: ["bar", "foo"] })).toEqual(3); + expect(subject({ name_start_any: ["a", "b", "c"] })).toEqual(2); + expect(subject({ name_matches_any: ["foo%", "%bar"] })).toEqual(3); + + expect(subject({ name_not_cont_all: ["bar", "foo"] })).toEqual(1); + expect(subject({ name_not_start_all: ["bar", "foo"] })).toEqual(1); + expect(subject({ name_does_not_match_all: ["az%", "ba%"] })).toEqual(2); + + expect(subject({ name_not_cont_any: ["bar", "foo"] })).toEqual(3); + expect(subject({ name_not_start_any: ["b", "f"] })).toEqual(4); + expect(subject({ name_not_end_any: ["o", "r"] })).toEqual(4); + expect(subject({ name_does_not_match_any: ["%", "%z"] })).toEqual(3); + + expect(subject({ age_eq: 20 })).toEqual(1); + expect(subject({ age_lt: 20 })).toEqual(1); + expect(subject({ age_lte: 20 })).toEqual(2); + expect(subject({ age_gt: 20 })).toEqual(1); + expect(subject({ age_gte: 20 })).toEqual(2); + expect(subject({ age_in: [20, 30] })).toEqual(2); + expect(subject({ age_null: 1 })).toEqual(1); + + expect(subject({ age_not_null: 1 })).toEqual(3); + + expect(subject({ age_lt_all: [21, 15] })).toEqual(1); + + expect(subject({ age_gte_any: [30, 20] })).toEqual(2); + + // second query is ignored because attribute is not found + // third query is ignored because predicate is not found + expect(subject({ name_eq: "foo", foo_eq: "bar", name_foo: 1 })).toEqual(1); + + $Profile.create({ + userId: 1, + bio: "foo", + enabled: true, + }); + $Profile.create({ userId: 2, bio: "", enabled: false }); + $Profile.create({ userId: 3, bio: null as any, enabled: false }); + + const subject2 = (params: any): number => + Profile.search(params).result().count(); + + expect(subject2({})).toEqual(3); + expect(subject2({ enabled_true: 1 })).toEqual(1); + expect(subject2({ enabled_false: 1 })).toEqual(2); + expect(subject2({ bio_blank: 1 })).toEqual(2); + expect(subject2({ bio_present: 1 })).toEqual(1); + + expect(subject({ Profile_bio_eq: "foo" })).toEqual(1); + expect(subject({ Profile_enabled_true: 1 })).toEqual(1); + expect(subject({ Profile_enabled_false: 1 })).toEqual(2); + expect(subject({ Profile_bio_blank: 1 })).toEqual(2); + expect(subject({ Profile_bio_present: 1 })).toEqual(1); + + // Ensure that jonis is executed only once + expect( + subject({ + Profile_bio_eq: "foo", + Profile_enabled_true: 1, + }) + ).toEqual(1); + + // use searchableScope + expect(subject({ bio_cont: "foo" })).toEqual(1); + + $post.create({ authorId: 1, tags: [$postTag.build({ name: "tag1" })] }); + $post.create({ authorId: 2, tags: [$postTag.build({ name: "" })] }); + // multi level joins + expect(subject({ posts_tags_name_eq: "tag1" })).toEqual(1); + expect(subject({ posts_tags_name_blank: 1 })).toEqual(1); + expect(subject({ posts_tags_name_present: 1 })).toEqual(1); +}); + +test("Relation#search()", () => { + $user.create({ age: 20, name: "foo" }); + $user.create({ age: 20, name: "bar" }); + $user.create({ age: 30, name: "foo" }); + + expect( + User.where({ age: 20 }).search({ name_eq: "foo" }).result().count() + ).toEqual(1); +}); diff --git a/tests/models/user.ts b/tests/models/user.ts index d87229b5..f077e77f 100644 --- a/tests/models/user.ts +++ b/tests/models/user.ts @@ -10,4 +10,12 @@ export class UserModel extends ApplicationRecord { static adults() { return this.where({ age: { ">=": 20 } }); } + + static bio_cont(value: string) { + return this.joins("Profile").where({ + Profile: { bio: { like: `%${value}%` } }, + }); + } + + static override searchableScopes = ["bio_cont"]; }