diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfad734..d1bd0b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.0...HEAD) - feat: add support for handling Retry-After header (#267) +- feat: add support for conflict options for Write operations**: (#276) + The client now supports setting `conflict` on `ClientWriteRequestOpts` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. + See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more. ## v0.9.0 diff --git a/README.md b/README.md index eea0eddb..70c5b631 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,85 @@ response = { */ ``` +#### Conflict Options for Write Operations + +The SDK supports conflict options for write operations, allowing you to control how the API handles duplicate writes and missing deletes. + +> **Note**: This requires OpenFGA [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. + +##### Using Conflict Options with Write +```javascript +const options = { + conflict: { + // Control what happens when writing a tuple that already exists + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, // or ClientWriteRequestOnDuplicateWrites.Error (the current default behavior) + // Control what happens when deleting a tuple that doesn't exist + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, // or ClientWriteRequestOnMissingDeletes.Error (the current default behavior) + } +}; + +const body = { + writes: [{ + user: 'user:anne', + relation: 'writer', + object: 'document:2021-budget', + }], + deletes: [{ + user: 'user:bob', + relation: 'reader', + object: 'document:2021-budget', + }], +}; + +const response = await fgaClient.write(body, options); +``` + +##### Using Conflict Options with WriteTuples +```javascript +const tuples = [{ + user: 'user:anne', + relation: 'writer', + object: 'document:2021-budget', +}]; + +const options = { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } +}; + +const response = await fgaClient.writeTuples(tuples, options); +``` + +##### Using Conflict Options with DeleteTuples +```javascript +const tuples = [{ + user: 'user:bob', + relation: 'reader', + object: 'document:2021-budget', +}]; + +const options = { + conflict: { + onMissingDeletes: OnMissingDelete.Ignore, + } +}; + +const response = await fgaClient.deleteTuples(tuples, options); +``` + +##### Conflict Options Behavior + +- **`onDuplicateWrites`**: + - `ClientWriteRequestOnDuplicateWrites.Error` (default): Returns an error if an identical tuple already exists (matching on user, relation, object, and condition) + - `ClientWriteRequestOnDuplicateWrites.Ignore`: Treats duplicate writes as no-ops, allowing idempotent write operations + +- **`onMissingDeletes`**: + - `ClientWriteRequestOnMissingDeletes.Error` (default): Returns an error when attempting to delete a tuple that doesn't exist + - `ClientWriteRequestOnMissingDeletes.Ignore`: Treats deletes of non-existent tuples as no-ops, allowing idempotent delete operations + +> **Important**: If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. + #### Relationship Queries ##### Check diff --git a/api.ts b/api.ts index 4a08e838..bee86ba7 100644 --- a/api.ts +++ b/api.ts @@ -677,7 +677,7 @@ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuratio }; }, /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body @@ -1024,7 +1024,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: }); }, /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body @@ -1239,7 +1239,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent return localVarFp.readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(axios)); }, /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body @@ -1467,7 +1467,7 @@ export class OpenFgaApi extends BaseAPI { } /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body diff --git a/apiModel.ts b/apiModel.ts index 1678c092..b5a8090b 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -1920,7 +1920,23 @@ export interface WriteRequestDeletes { * @memberof WriteRequestDeletes */ tuple_keys: Array; + /** + * On \'error\', the API returns an error when deleting a tuple that does not exist. On \'ignore\', deletes of non-existent tuples are treated as no-ops. + * @type {string} + * @memberof WriteRequestDeletes + */ + on_missing?: WriteRequestDeletesOnMissing; } + +/** +* @export +* @enum {string} +*/ +export enum WriteRequestDeletesOnMissing { + Error = 'error', + Ignore = 'ignore' +} + /** * * @export @@ -1933,5 +1949,21 @@ export interface WriteRequestWrites { * @memberof WriteRequestWrites */ tuple_keys: Array; + /** + * On \'error\' ( or unspecified ), the API returns an error if an identical tuple already exists. On \'ignore\', identical writes are treated as no-ops (matching on user, relation, object, and RelationshipCondition). + * @type {string} + * @memberof WriteRequestWrites + */ + on_duplicate?: WriteRequestWritesOnDuplicate; } +/** +* @export +* @enum {string} +*/ +export enum WriteRequestWritesOnDuplicate { + Error = 'error', + Ignore = 'ignore' +} + + diff --git a/client.ts b/client.ts index d6278fa1..f55b301e 100644 --- a/client.ts +++ b/client.ts @@ -36,6 +36,8 @@ import { WriteAuthorizationModelRequest, WriteAuthorizationModelResponse, WriteRequest, + WriteRequestWritesOnDuplicate, + WriteRequestDeletesOnMissing, } from "./apiModel"; import { BaseAPI } from "./base"; import { CallResult, PromiseResult } from "./common"; @@ -175,12 +177,40 @@ export interface ClientBatchCheckResponse { result: ClientBatchCheckSingleResponse[]; } +export const ClientWriteRequestOnDuplicateWrites = WriteRequestWritesOnDuplicate; +export const ClientWriteRequestOnMissingDeletes = WriteRequestDeletesOnMissing; + +export type ClientWriteRequestOnDuplicateWrites = WriteRequestWritesOnDuplicate; +export type ClientWriteRequestOnMissingDeletes = WriteRequestDeletesOnMissing; + +export interface ClientWriteConflictOptions { + onDuplicateWrites?: ClientWriteRequestOnDuplicateWrites; + onMissingDeletes?: ClientWriteRequestOnMissingDeletes; +} + +export interface ClientWriteTransactionOptions { + disable?: boolean; + maxPerChunk?: number; + maxParallelRequests?: number; +} + export interface ClientWriteRequestOpts { - transaction?: { - disable?: boolean; - maxPerChunk?: number; - maxParallelRequests?: number; - } + transaction?: ClientWriteTransactionOptions; + conflict?: ClientWriteConflictOptions; +} + +export interface ClientWriteTuplesRequestOpts { + transaction?: ClientWriteTransactionOptions; + conflict?: { + onDuplicateWrites?: ClientWriteRequestOnDuplicateWrites; + }; +} + +export interface ClientDeleteTuplesRequestOpts { + transaction?: ClientWriteTransactionOptions; + conflict?: { + onMissingDeletes?: ClientWriteRequestOnMissingDeletes; + }; } export interface ClientWriteRequest { @@ -462,6 +492,9 @@ export class OpenFgaClient extends BaseAPI { * @param {ClientWriteRequest} body * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {object} [options.conflict] - Conflict handling options + * @param {ClientWriteRequestOnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `ClientWriteRequestOnDuplicateWrites.Error` + * @param {ClientWriteRequestOnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `ClientWriteRequestOnMissingDeletes.Error` * @param {object} [options.transaction] * @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false` * @param {number} [options.transaction.maxPerChunk] - Max number of items to send in a single transaction chunk. Defaults to `1` @@ -472,7 +505,7 @@ export class OpenFgaClient extends BaseAPI { * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ async write(body: ClientWriteRequest, options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise { - const { transaction = {}, headers = {} } = options; + const { transaction = {}, headers = {}, conflict } = options; const { maxPerChunk = 1, // 1 has to be the default otherwise the chunks will be sent in transactions maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, @@ -485,10 +518,16 @@ export class OpenFgaClient extends BaseAPI { authorization_model_id: authorizationModelId, }; if (writes?.length) { - apiBody.writes = { tuple_keys: writes }; + apiBody.writes = { + tuple_keys: writes, + on_duplicate: conflict?.onDuplicateWrites ?? ClientWriteRequestOnDuplicateWrites.Error + }; } if (deletes?.length) { - apiBody.deletes = { tuple_keys: deletes }; + apiBody.deletes = { + tuple_keys: deletes, + on_missing: conflict?.onMissingDeletes ?? ClientWriteRequestOnMissingDeletes.Error + }; } await this.api.write(this.getStoreId(options)!, apiBody, options); return { @@ -509,7 +548,7 @@ export class OpenFgaClient extends BaseAPI { const writeResponses: ClientWriteSingleResponse[][] = []; if (writes?.length) { for await (const singleChunkResponse of asyncPool(maxParallelRequests, chunkArray(writes, maxPerChunk), - (chunk) => this.writeTuples(chunk,{ ...options, headers, transaction: undefined }).catch(err => { + (chunk) => this.writeTuples(chunk,{ ...options, headers, conflict, transaction: undefined }).catch(err => { if (err instanceof FgaApiAuthenticationError) { throw err; } @@ -529,7 +568,7 @@ export class OpenFgaClient extends BaseAPI { const deleteResponses: ClientWriteSingleResponse[][] = []; if (deletes?.length) { for await (const singleChunkResponse of asyncPool(maxParallelRequests, chunkArray(deletes, maxPerChunk), - (chunk) => this.deleteTuples(chunk, { ...options, headers, transaction: undefined }).catch(err => { + (chunk) => this.deleteTuples(chunk, { ...options, headers, conflict, transaction: undefined }).catch(err => { if (err instanceof FgaApiAuthenticationError) { throw err; } @@ -552,8 +591,10 @@ export class OpenFgaClient extends BaseAPI { /** * WriteTuples - Utility method to write tuples, wraps Write * @param {TupleKey[]} tuples - * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] + * @param {ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {object} [options.conflict] - Conflict handling options + * @param {ClientWriteRequestOnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `ClientWriteRequestOnDuplicateWrites.Error` * @param {object} [options.transaction] * @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false` * @param {number} [options.transaction.maxPerChunk] - Max number of items to send in a single transaction chunk. Defaults to `1` @@ -563,7 +604,7 @@ export class OpenFgaClient extends BaseAPI { * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ - async writeTuples(tuples: TupleKey[], options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise { + async writeTuples(tuples: TupleKey[], options: ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts = {}): Promise { const { headers = {} } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "WriteTuples"); return this.write({ writes: tuples }, { ...options, headers }); @@ -572,8 +613,10 @@ export class OpenFgaClient extends BaseAPI { /** * DeleteTuples - Utility method to delete tuples, wraps Write * @param {TupleKeyWithoutCondition[]} tuples - * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] + * @param {ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {object} [options.conflict] - Conflict handling options + * @param {ClientWriteRequestOnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `ClientWriteRequestOnMissingDeletes.Error` * @param {object} [options.transaction] * @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false` * @param {number} [options.transaction.maxPerChunk] - Max number of items to send in a single transaction chunk. Defaults to `1` @@ -583,7 +626,7 @@ export class OpenFgaClient extends BaseAPI { * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ - async deleteTuples(tuples: TupleKeyWithoutCondition[], options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise { + async deleteTuples(tuples: TupleKeyWithoutCondition[], options: ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts = {}): Promise { const { headers = {} } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "DeleteTuples"); return this.write({ deletes: tuples }, { ...options, headers }); diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index dae177fc..5591da76 100644 --- a/example/example1/example1.mjs +++ b/example/example1/example1.mjs @@ -1,4 +1,4 @@ -import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName } from "@openfga/sdk"; +import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, ClientWriteRequestOnDuplicateWrites } from "@openfga/sdk"; import { randomUUID } from "crypto"; async function main () { @@ -145,7 +145,10 @@ async function main () { object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a" } ] - }, { authorizationModelId }); + }, { + authorizationModelId, + conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } + }); console.log("Done Writing Tuples"); // Set the model ID diff --git a/tests/client.test.ts b/tests/client.test.ts index a8c06fc5..77119cfb 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -11,6 +11,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -462,6 +464,625 @@ describe("OpenFGA Client", () => { expect(data.writes.length).toBe(1); expect(data.deletes.length).toBe(0); }); + + describe("with conflict options", () => { + it("should pass onDuplicateWrites Ignore option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [tuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onDuplicateWrites Error option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [tuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDeletes Ignore option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [tuple], + }, { + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDeletes Error option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [tuple], + }, { + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass both conflict options to API", async () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when conflict options are not specified", async () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + describe("matrix tests for writes only", () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + it("should handle writes only with onDuplicateWrites Error", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle writes only with onDuplicateWrites Ignore", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + + describe("matrix tests for deletes only", () => { + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + it("should handle deletes only with onMissingDeletes Error", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [deleteTuple], + }, { + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle deletes only with onMissingDeletes Ignore", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [deleteTuple], + }, { + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [deleteTuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + + describe("matrix tests for mixed writes and deletes", () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + it("should handle mixed writes and deletes with (Ignore, Ignore)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle mixed writes and deletes with (Ignore, Error)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle mixed writes and deletes with (Error, Ignore)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle mixed writes and deletes with (Error, Error)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + + describe("with transaction.disable and conflict options", () => { + it("should pass conflict options when transaction is disabled for writes", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [tuple], + }, { + transaction: { disable: true }, + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass conflict options when transaction is disabled for deletes", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [tuple], + }, { + transaction: { disable: true }, + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass both conflict options when transaction is disabled with mixed writes and deletes", async () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + transaction: { disable: true }, + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledTimes(2); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle multiple chunks with conflict options in non-transaction mode", async () => { + const tuples = [ + { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }, + { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + } + ]; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: tuples, + }, { + transaction: { + disable: true, + maxPerChunk: 1, // Force 2 separate calls + }, + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledTimes(2); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: expect.objectContaining({ + on_duplicate: "ignore", + }), + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + }); }); describe("WriteTuples", () => { @@ -481,6 +1102,89 @@ describe("OpenFGA Client", () => { expect(scope.isDone()).toBe(true); expect(data).toMatchObject({}); }); + + it("should pass onDuplicateWrites Ignore option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.writeTuples([tuple], { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onDuplicateWrites Error option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.writeTuples([tuple], { + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when onDuplicateWrites option is not specified", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.writeTuples([tuple]); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); }); describe("DeleteTuples", () => { @@ -500,6 +1204,89 @@ describe("OpenFGA Client", () => { expect(scope.isDone()).toBe(true); expect(data).toMatchObject({}); }); + + it("should pass onMissingDeletes Ignore option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.deleteTuples([tuple], { + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDeletes Error option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.deleteTuples([tuple], { + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when onMissingDeletes option is not specified", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.deleteTuples([tuple]); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); }); /* Relationship Queries */