1:   2:   3:   4:   5:   6:   7:   8:   9:  10:  11:  12:  13:  14:  15:  16:  17:  18:  19:  20:  21:  22:  23:  24:  25:  26:  27:  28:  29:  30:  31:  32:  33:  34:  35:  36:  37:  38:  39:  40:  41:  42:  43:  44:  45:  46:  47:  48:  49:  50:  51:  52:  53:  54:  55:  56:  57:  58:  59:  60:  61:  62:  63:  64:  65:  66:  67:  68:  69:  70:  71:  72:  73:  74:  75:  76:  77:  78:  79:  80:  81:  82:  83:  84:  85:  86:  87:  88:  89:  90:  91:  92:  93:  94:  95:  96:  97:  98:  99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225: 226: 227: 228: 229: 230: 231: 232: 233: 234: 235: 236: 237: 238: 239: 240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250: 251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261: 262: 263: 264: 265: 266: 267: 268: 269: 270: 271: 272: 273: 274: 275: 276: 277: 278: 279: 280: 281: 282: 283: 284: 285: 286: 287: 288: 289: 290: 291: 292: 293: 294: 295: 296: 297: 298: 299: 300: 301: 302: 303: 304: 305: 306: 307: 308: 309: 310: 311: 312: 313: 314: 315: 316: 317: 318: 319: 320: 321: 322: 323: 324: 325: 326: 327: 328: 329: 330: 331: 332: 333: 334: 335: 336: 337: 338: 339: 340: 341: 342: 343: 344: 345: 346: 347: 348: 349: 350: 351: 352: 353: 354: 355: 356: 357: 358: 359: 360: 361: 362: 363: 364: 365: 366: 367: 368: 369: 370: 371: 372: 373: 374: 375: 376: 377: 378: 379: 380: 381: 382: 383: 384: 385: 386: 387: 388: 389: 390: 391: 392: 393: 394: 395: 396: 397: 398: 399: 400: 401: 402: 403: 404: 405: 406: 407: 408: 409: 410: 411: 412: 413: 414: 415: 416: 417: 418: 419: 420: 421: 422: 423: 424: 425: 426: 427: 428: 429: 430: 431: 432: 433: 434: 435: 436: 437: 438: 439: 440: 441: 442: 443: 444: 445: 446: 447: 448: 449: 450: 451: 452: 453: 454: 455: 456: 457: 458: 459: 460: 461: 462: 463: 464: 465: 466: 467: 468: 469: 470: 471: 472: 473: 474: 475: 476: 477: 478: 479: 480: 481: 482: 483: 484: 485: 486: 487: 488: 489: 490: 491: 492: 493: 494: 495: 496: 497: 498: 499: 500: 501: 502: 503: 504: 505: 506: 507: 508: 509: 510: 511: 512: 513: 514: 515: 516: 517: 518: 519: 520: 521: 522: 523: 524: 525: 526: 527: 528: 529: 530: 531: 532: 533: 534: 535: 536: 537: 538: 539: 540: 541: 542: 543: 544: 545: 546: 547: 548: 549: 550: 551: 552: 553: 554: 555: 556: 557: 558: 559: 560: 561: 562: 563: 564: 565: 566: 567: 568: 569: 570: 571: 572: 573: 574: 575: 576: 577: 578: 579: 580: 581: 582: 583: 584: 585: 586: 587: 588: 589: 590: 591: 592: 593: 594: 595: 596: 597: 598: 599: 600: 601: 602: 603: 604: 605: 606: 607: 608: 609: 610: 611: 612: 613: 614: 615: 616: 617: 618: 619: 620: 621: 622: 623: 624: 625: 626: 627: 628: 629: 630: 631: 632: 633: 634: 635: 636: 637: 638: 639: 640: 641: 642: 643: 644: 645: 646: 647: 648: 649: 650: 651: 652: 653: 654: 655: 656: 657: 658: 659: 660: 661: 662: 663: 664: 665: 666: 667: 668: 669: 670: 671: 672: 673: 674: 675: 676: 677: 678: 679: 680: 681: 682: 683: 684: 685: 686: 687: 688: 689: 690: 691: 692: 693: 694: 695: 696: 697: 698: 699: 700: 701: 702: 703: 704: 705: 706: 707: 708: 709: 710: 711: 712: 713: 714: 715: 716: 717: 718: 719: 720: 721: 722: 723: 724: 725: 726: 727: 728: 729: 730: 731: 732: 733: 734: 735: 736: 737: 738: 739: 740: 741: 742: 743: 744: 745: 746: 747: 748: 749: 750: 751: 752: 753: 754: 755: 756: 757: 758: 759: 760: 761: 762: 763: 764: 765: 766: 767: 768: 769: 770: 771: 772: 773: 774: 775: 776: 777: 778: 779: 780: 781: 782: 783: 784: 785: 786: 787: 788: 789: 790: 791: 792: 793: 794: 795: 796: 797: 798: 799: 800: 801: 802: 803: 804: 805: 806: 807: 808: 809: 810: 811: 812: 813: 814: 815: 816: 817: 818: 819: 820: 821: 822: 823: 824: 825: 826: 827: 828: 829: 830: 831: 832: 833: 834: 835: 836: 837: 838: 839: 840: 841: 842: 
<?php
namespace Omeka\Api\Adapter;

use DateTime;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Omeka\Api\Exception;
use Omeka\Api\Request;
use Omeka\Api\Response;
use Omeka\Entity\User;
use Omeka\Entity\EntityInterface;
use Omeka\Stdlib\ErrorStore;
use Zend\EventManager\Event;

/**
 * Abstract entity API adapter.
 */
abstract class AbstractEntityAdapter extends AbstractAdapter implements EntityAdapterInterface
{
    /**
     * A unique token index for query builder aliases and placeholders.
     *
     * @var int
     */
    protected $index = 0;

    /**
     * Entity fields on which to sort search results.
     *
     * The keys are the value of "sort_by" query. The values are the
     * corresponding entity fields on which to sort.
     *
     * @see self::sortQuery()
     * @var array
     */
    protected $sortFields = [];

    /**
     * Hydrate an entity with the provided array.
     *
     * Validation should be done in {@link self::validateRequest()} or
     * {@link self::validateEntity()}. Filtering should be done in the entity's
     * mutator methods. Authorize state changes of individual fields using
     * {@link self::authorize()}.
     *
     * @param Request $request
     * @param EntityInterface $entity
     * @param ErrorStore $errorStore
     */
    abstract public function hydrate(Request $request, EntityInterface $entity,
        ErrorStore $errorStore);

    /**
     * Validate entity data.
     *
     * This happens before entity hydration. Only use this for validations that
     * don't require a hydrated entity, typically limited to validating for
     * expected data format and internal consistency. Set validation errors to
     * the passed $errorStore object. If an error is set the entity will not be
     * hydrated, created, or updated.
     *
     * @param array $data
     * @param ErrorStore $errorStore
     */
    public function validateRequest(Request $request, ErrorStore $errorStore)
    {
    }

    /**
     * Validate an entity.
     *
     * This happens after entity hydration. Use this method for validations
     * that require a hydrated entity (i.e. most validations). Set validation
     * errors to the passed $errorStore object. If an error is set the entity
     * will not be created or updated.
     *
     * @param EntityInterface $entity
     * @param ErrorStore $errorStore
     */
    public function validateEntity(EntityInterface $entity, ErrorStore $errorStore)
    {
    }

    /**
     * Build a conditional search query from an API request.
     *
     * Modify the passed query builder object according to the passed $query
     * data. The sort_by, sort_order, page, limit, and offset parameters are
     * included separately.
     *
     * @link http://docs.doctrine-project.org/en/latest/reference/query-builder.html
     * @param QueryBuilder $qb
     * @param array $query
     */
    public function buildQuery(QueryBuilder $qb, array $query)
    {
    }

    /**
     * Set sort_by and sort_order conditions to the query builder.
     *
     * @param QueryBuilder $qb
     * @param array $query
     */
    public function sortQuery(QueryBuilder $qb, array $query)
    {
        if (is_string($query['sort_by'])
            && array_key_exists($query['sort_by'], $this->sortFields)
        ) {
            $sortBy = $this->sortFields[$query['sort_by']];
            $qb->addOrderBy($this->getEntityClass() . ".$sortBy", $query['sort_order']);
        }
    }

    /**
     * Sort a query by inverse association count.
     *
     * @param QueryBuilder $qb
     * @param array $query
     * @param string $inverseField The name of the inverse association field.
     * @param string|null $instanceOf A fully qualified entity class name. If
     * provided, count only these instances.
     */
    public function sortByCount(QueryBuilder $qb, array $query,
        $inverseField, $instanceOf = null
    ) {
        $entityAlias = $this->getEntityClass();
        $inverseAlias = $this->createAlias();
        $countAlias = $this->createAlias();

        $qb->addSelect("COUNT($inverseAlias.id) HIDDEN $countAlias");
        if ($instanceOf) {
            $qb->leftJoin(
                "$entityAlias.$inverseField", $inverseAlias,
                'WITH', "$inverseAlias INSTANCE OF $instanceOf"
            );
        } else {
            $qb->leftJoin("$entityAlias.$inverseField", $inverseAlias);
        }
        $qb->addOrderBy($countAlias, $query['sort_order']);
    }

    /**
     * Set page, limit (max results) and offset (first result) conditions to the
     * query builder.
     *
     * @param array $query
     * @param QueryBuilder $qb
     */
    public function limitQuery(QueryBuilder $qb, array $query)
    {
        if (is_numeric($query['page'])) {
            $paginator = $this->getServiceLocator()->get('Omeka\Paginator');
            $paginator->setCurrentPage($query['page']);
            if (is_numeric($query['per_page'])) {
                $paginator->setPerPage($query['per_page']);
            }
            $qb->setMaxResults($paginator->getPerPage());
            $qb->setFirstResult($paginator->getOffset());
            return;
        }
        if (is_numeric($query['limit'])) {
            $qb->setMaxResults($query['limit']);
        }
        if (is_numeric($query['offset'])) {
            $qb->setFirstResult($query['offset']);
        }
    }

    /**
     * {@inheritDoc}
     */
    public function search(Request $request)
    {
        $query = $request->getContent();

        // Set default query parameters
        if (!isset($query['page'])) {
            $query['page'] = null;
        }
        if (!isset($query['per_page'])) {
            $query['per_page'] = null;
        }
        if (!isset($query['limit'])) {
            $query['limit'] = null;
        }
        if (!isset($query['offset'])) {
            $query['offset'] = null;
        }
        if (!isset($query['sort_by'])) {
            $query['sort_by'] = null;
        }
        if (isset($query['sort_order'])
            && in_array(strtoupper($query['sort_order']), ['ASC', 'DESC'])
        ) {
            $query['sort_order'] = strtoupper($query['sort_order']);
        } else {
            $query['sort_order'] = 'ASC';
        }

        // Begin building the search query.
        $entityClass = $this->getEntityClass();
        $this->index = 0;
        $qb = $this->getEntityManager()
            ->createQueryBuilder()
            ->select($entityClass)
            ->from($entityClass, $entityClass);
        $this->buildQuery($qb, $query);
        $qb->groupBy("$entityClass.id");

        // Trigger the search.query event.
        $event = new Event('api.search.query', $this, [
            'queryBuilder' => $qb,
            'request' => $request,
        ]);
        $this->getEventManager()->triggerEvent($event);

        // Finish building the search query. In addition to any sorting the
        // adapters add, always sort by entity ID.
        $this->sortQuery($qb, $query);
        $this->limitQuery($qb, $query);
        $qb->addOrderBy("$entityClass.id", $query['sort_order']);

        $scalarField = $request->getOption('returnScalar');
        if ($scalarField) {
            $fieldNames = $this->getEntityManager()->getClassMetadata($entityClass)->getFieldNames();
            if (!in_array($scalarField, $fieldNames)) {
                throw new Exception\BadRequestException(sprintf(
                    $this->getTranslator()->translate('The "%s" field is not available in the %s entity class.'),
                    $scalarField, $entityClass
                ));
            }
            $qb->select(sprintf('%s.%s', $entityClass, $scalarField));
            $content = array_column($qb->getQuery()->getScalarResult(), $scalarField);
            $response = new Response($content);
            $response->setTotalResults(count($content));
            return $response;
        }

        $paginator = new Paginator($qb, false);
        $entities = [];
        // Don't make the request if the LIMIT is set to zero. Useful if the
        // only information needed is total results.
        if ($qb->getMaxResults() || null === $qb->getMaxResults()) {
            foreach ($paginator as $entity) {
                if (is_array($entity)) {
                    // Remove non-entity columns added to the SELECT. You can use
                    // "AS HIDDEN {alias}" to avoid this condition.
                    $entity = $entity[0];
                }
                $entities[] = $entity;
            }
        }

        $response = new Response($entities);
        $response->setTotalResults($paginator->count());
        return $response;
    }

    /**
     * {@inheritDoc}
     */
    public function create(Request $request)
    {
        $entityClass = $this->getEntityClass();
        $entity = new $entityClass;
        $this->hydrateEntity($request, $entity, new ErrorStore);
        $this->getEntityManager()->persist($entity);
        if ($request->getOption('flushEntityManager', true)) {
            $this->getEntityManager()->flush();
            // Refresh the entity on the chance that it contains associations
            // that have not been loaded.
            $this->getEntityManager()->refresh($entity);
        }
        return new Response($entity);
    }

    /**
     * Batch create entities.
     *
     * Preserves the keys of the request content array as the keys of the
     * response content array. This is helpful for implementations that need to
     * map original identifiers to the newly created entity IDs.
     *
     * There are two outcomes if an exception is thrown during a batch. If
     * continueOnError is set to the request, the current entity is thrown away
     * but the operation continues. Otherwise, all previously persisted entities
     * are detached from the entity manager.
     *
     * Detaches entities after they've been created to minimize memory usage.
     * Because the entities are detached, this returns resource references
     * (containing only the entity ID) instead of full entity representations.
     *
     * {@inheritDoc}
     */
    public function batchCreate(Request $request)
    {
        $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager');
        $logger = $this->getServiceLocator()->get('Omeka\Logger');

        $subresponses = [];
        $subrequestOptions = [
            'flushEntityManager' => false, // Flush once, after persisting all entities
            'responseContent' => 'resource', // Return entities to work directly on them
            'finalize' => false, // Finalize only after flushing entities
        ];
        foreach ($request->getContent() as $key => $subrequestData) {
            try {
                $subresponse = $apiManager->create(
                    $request->getResource(), $subrequestData, [], $subrequestOptions
                );
            } catch (\Exception $e) {
                if ($request->getOption('continueOnError', false)) {
                    $logger->err((string) $e);
                    continue;
                }
                // Detatch previously persisted entities before re-throwing.
                foreach ($subresponses as $subresponse) {
                    $this->getEntityManager()->detach($subresponse->getContent());
                }
                throw $e;
            }
            $subresponses[$key] = $subresponse;
        }
        $this->getEntityManager()->flush();

        $entities = [];
        // Iterate each subresponse to finalize the execution of each created
        // entity; to detach each entity to ease subsequent flushes; and to
        // build response content.
        foreach ($subresponses as $key => $subresponse) {
            $apiManager->finalize($this, $subresponse->getRequest(), $subresponse);
            $entity = $subresponse->getContent();
            $this->getEntityManager()->detach($entity);
            $entities[$key] = $entity;
        }

        $request->setOption('responseContent', 'reference');
        return new Response($entities);
    }

    /**
     * {@inheritDoc}
     */
    public function read(Request $request)
    {
        $entity = $this->findEntity($request->getId(), $request);
        $this->authorize($entity, Request::READ);
        $event = new Event('api.find.post', $this, [
            'entity' => $entity,
            'request' => $request,
        ]);
        $this->getEventManager()->triggerEvent($event);
        return new Response($entity);
    }

    /**
     * {@inheritDoc}
     */
    public function update(Request $request)
    {
        $entity = $this->findEntity($request->getId(), $request);
        $this->hydrateEntity($request, $entity, new ErrorStore);
        if ($request->getOption('flushEntityManager', true)) {
            $this->getEntityManager()->flush();
        }
        return new Response($entity);
    }

    /**
     * {@inheritDoc}
     */
    public function batchUpdate(Request $request)
    {
        $data = $this->preprocessBatchUpdate([], $request);

        $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager');
        $logger = $this->getServiceLocator()->get('Omeka\Logger');

        $subresponses = [];
        $subrequestOptions = [
            'isPartial' => true, // Batch updates are always partial updates
            'collectionAction' => $request->getOption('collectionAction', 'replace'), // collection action carries over from parent request
            'flushEntityManager' => false, // Flush once, after hydrating all entities
            'responseContent' => 'resource', // Return entities to work directly on them
            'finalize' => false, // Finalize only after flushing entities
        ];
        foreach ($request->getIds() as $key => $id) {
            try {
                $subresponse = $apiManager->update(
                    $request->getResource(), $id, $data, [], $subrequestOptions
                );
            } catch (\Exception $e) {
                if ($request->getOption('continueOnError', false)) {
                    $logger->err((string) $e);
                    continue;
                }
                // Detatch managed entities before re-throwing.
                foreach ($subresponses as $subresponse) {
                    $this->getEntityManager()->detach($subresponse->getContent());
                }
                throw $e;
            }
            $subresponses[$key] = $subresponse;
        }
        $this->getEntityManager()->flush();

        $entities = [];
        // Iterate each subresponse to finalize the execution of each updated
        // entity; to detach each entity to ease subsequent flushes; and to
        // build response content.
        foreach ($subresponses as $key => $subresponse) {
            $apiManager->finalize($this, $subresponse->getRequest(), $subresponse);
            $entity = $subresponse->getContent();
            $this->getEntityManager()->detach($entity);
            $entities[$key] = $entity;
        }

        $request->setOption('responseContent', 'reference');
        return new Response($entities);
    }

    /**
     * {@inheritDoc}
     */
    public function delete(Request $request)
    {
        $entity = $this->deleteEntity($request);
        if ($request->getOption('flushEntityManager', true)) {
            $this->getEntityManager()->flush();
        }
        return new Response($entity);
    }

    /**
     * {@inheritDoc}
     */
    public function batchDelete(Request $request)
    {
        $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager');
        $logger = $this->getServiceLocator()->get('Omeka\Logger');

        $subresponses = [];
        $subrequestOptions = [
            'flushEntityManager' => false, // Flush once, after removing all entities
            'responseContent' => 'resource', // Return entities to work directly on them
            'finalize' => false, // Finalize only after flushing entities
        ];
        foreach ($request->getIds() as $key => $id) {
            try {
                $subresponse = $apiManager->delete(
                    $request->getResource(), $id, [], $subrequestOptions
                );
            } catch (\Exception $e) {
                if ($request->getOption('continueOnError', false)) {
                    $logger->err((string) $e);
                    continue;
                }
                // Detatch managed entities before re-throwing.
                foreach ($subresponses as $subresponse) {
                    $this->getEntityManager()->detach($subresponse->getContent());
                }
                throw $e;
            }
            $subresponses[$key] = $subresponse;
        }
        $this->getEntityManager()->flush();

        $entities = [];
        // Iterate each subresponse to finalize the execution of each deleted
        // entity; to detach each entity to ease subsequent flushes; and to
        // build response content.
        foreach ($subresponses as $key => $subresponse) {
            $apiManager->finalize($this, $subresponse->getRequest(), $subresponse);
            $entity = $subresponse->getContent();
            $this->getEntityManager()->detach($entity);
            $entities[$key] = $entity;
        }

        $request->setOption('responseContent', 'reference');
        return new Response($entities);
    }

    /**
     * Get the entity manager.
     *
     * @return \Doctrine\ORM\EntityManager
     */
    public function getEntityManager()
    {
        return $this->getServiceLocator()->get('Omeka\EntityManager');
    }

    /**
     * Delete an entity.
     *
     * Encapsulates finding, authorization, post-find event, and removal into
     * one method.
     *
     * @param Request $request
     * @return EntityInterface
     */
    public function deleteEntity(Request $request)
    {
        $entity = $this->findEntity($request->getId(), $request);
        $this->authorize($entity, Request::DELETE);
        $event = new Event('api.find.post', $this, [
            'entity' => $entity,
            'request' => $request,
        ]);
        $this->getEventManager()->triggerEvent($event);
        $this->getEntityManager()->remove($entity);
        return $entity;
    }

    /**
     * Hydrate an entity.
     *
     * Encapsulates hydration, authorization, pre-validation API events, and
     * validation procedures into one method.
     *
     * @throws Exception\ValidationException
     * @param Request $request
     * @param EntityInterface $entity
     * @param ErrorStore $errorStore
     */
    public function hydrateEntity(Request $request,
        EntityInterface $entity, ErrorStore $errorStore
    ) {
        $operation = $request->getOperation();
        // Before everything, check whether the current user has access to this
        // entity in its original state.
        $this->authorize($entity, $operation);

        // Trigger the operation's api.hydrate.pre event.
        $event = new Event('api.hydrate.pre', $this, [
            'entity' => $entity,
            'request' => $request,
            'errorStore' => $errorStore,
        ]);
        $this->getEventManager()->triggerEvent($event);

        // Validate the request.
        $this->validateRequest($request, $errorStore);

        if ($errorStore->hasErrors()) {
            $validationException = new Exception\ValidationException;
            $validationException->setErrorStore($errorStore);
            throw $validationException;
        }

        $this->hydrate($request, $entity, $errorStore);

        // Trigger the operation's api.hydrate.post event.
        $event = new Event('api.hydrate.post', $this, [
            'entity' => $entity,
            'request' => $request,
            'errorStore' => $errorStore,
        ]);
        $this->getEventManager()->triggerEvent($event);

        // Validate the entity.
        $this->validateEntity($entity, $errorStore);

        if ($errorStore->hasErrors()) {
            if (Request::UPDATE == $operation) {
                // Refresh the entity from the database, overriding any local
                // changes that have not yet been persisted
                $this->getEntityManager()->refresh($entity);
            }
            $validationException = new Exception\ValidationException;
            $validationException->setErrorStore($errorStore);
            throw $validationException;
        }
    }

    /**
     * Verify that the current user has access to the entity.
     *
     * @throws Exception\PermissionDeniedException
     * @param EntityInterface $entity
     * @param string $privilege
     */
    protected function authorize(EntityInterface $entity, $privilege)
    {
        $acl = $this->getServiceLocator()->get('Omeka\Acl');
        if (!$acl->userIsAllowed($entity, $privilege)) {
            throw new Exception\PermissionDeniedException(sprintf(
                $this->getTranslator()->translate(
                    'Permission denied for the current user to %s the %s resource.'
                ),
                $privilege, $entity->getResourceId()
            ));
        }
    }

    /**
     * Find a single entity by criteria.
     *
     * @throws Exception\NotFoundException
     * @param mixed $criteria
     * @param Request|null $request
     * @return EntityInterface
     */
    public function findEntity($criteria, $request = null)
    {
        if (!is_array($criteria)) {
            $criteria = ['id' => $criteria];
        }

        $entityClass = $this->getEntityClass();
        $this->index = 0;
        $qb = $this->getEntityManager()->createQueryBuilder();
        $qb->select($entityClass)->from($entityClass, $entityClass);
        foreach ($criteria as $field => $value) {
            $qb->andWhere($qb->expr()->eq(
                "$entityClass.$field",
                $this->createNamedParameter($qb, $value)
            ));
        }
        $qb->setMaxResults(1);

        $event = new Event('api.find.query', $this, [
            'queryBuilder' => $qb,
            'request' => $request,
        ]);

        $this->getEventManager()->triggerEvent($event);
        $entity = $qb->getQuery()->getOneOrNullResult();
        if (!$entity) {
            throw new Exception\NotFoundException(sprintf(
                $this->getTranslator()->translate('%s entity with criteria %s not found'),
                $this->getEntityClass(), json_encode($criteria)
            ));
        }
        return $entity;
    }

    /**
     * Create a unique named parameter for the query builder and bind a value to
     * it.
     *
     * @param QueryBuilder $qb
     * @param mixed $value The value to bind
     * @param string $prefix The placeholder prefix
     * @return string The placeholder
     */
    public function createNamedParameter(QueryBuilder $qb, $value,
        $prefix = 'omeka_'
    ) {
        $placeholder = $prefix . $this->index;
        $this->index++;
        $qb->setParameter($placeholder, $value);
        return ":$placeholder";
    }

    /**
     * Create a unique alias for the query builder.
     *
     * @param string $prefix The alias prefix
     * @return string The alias
     */
    public function createAlias($prefix = 'omeka_')
    {
        $alias = $prefix . $this->index;
        $this->index++;
        return $alias;
    }

    /**
     * Determine whether a string is a valid JSON-LD term.
     *
     * @param string $term
     * @return bool
     */
    public function isTerm($term)
    {
        return (bool) preg_match('/^[a-z0-9-_]+:[a-z0-9-_]+$/i', $term);
    }

    /**
     * Check for uniqueness by a set of criteria.
     *
     * @param EntityInterface $entity
     * @param array $criteria Keys are fields to check, values are strings to
     * check against. An entity may be passed as a value.
     * @return bool
     */
    public function isUnique(EntityInterface $entity, array $criteria)
    {
        $this->index = 0;
        $qb = $this->getEntityManager()->createQueryBuilder();
        $qb->select('e.id')
            ->from($this->getEntityClass(), 'e');

        // Exclude the passed entity from the query if it has an persistent
        // indentifier.
        if ($entity->getId()) {
            $qb->andWhere($qb->expr()->neq(
                'e.id',
                $this->createNamedParameter($qb, $entity->getId())
            ));
        }

        foreach ($criteria as $field => $value) {
            $qb->andWhere($qb->expr()->eq(
                "e.$field",
                $this->createNamedParameter($qb, $value)
            ));
        }
        return null === $qb->getQuery()->getOneOrNullResult();
    }

    /**
     * Check whether to hydrate on a key.
     *
     * @param Request $request
     * @param string $key
     * @return bool
     */
    public function shouldHydrate(Request $request, $key)
    {
        if ($request->getOperation() === Request::UPDATE
            && $request->getOption('isPartial', false)
        ) {
            // Conditionally hydrate on partial update operation.
            return array_key_exists($key, $request->getContent());
        }
        // Always hydrate on create and update operations.
        return true;
    }

    /**
     * Hydrate the entity's owner.
     *
     * Assumes the owner can be set to NULL. By default, new entities are owned
     * by the current user.
     *
     * This diverges from the conventional hydration pattern for an update
     * operation. Normally the absence of [o:owner] would set the value to null.
     * In this case [o:owner][o:id] must explicitly be set to null.
     *
     * @param Request $request
     * @param EntityInterface $entity
     */
    public function hydrateOwner(Request $request, EntityInterface $entity)
    {
        $data = $request->getContent();
        $owner = $entity->getOwner();
        if ($this->shouldHydrate($request, 'o:owner')) {
            if (array_key_exists('o:owner', $data)
                && is_array($data['o:owner'])
                && array_key_exists('o:id', $data['o:owner'])
            ) {
                $newOwnerId = $data['o:owner']['o:id'];
                $newOwnerId = is_numeric($newOwnerId) ? (int) $newOwnerId : null;

                $oldOwnerId = $owner ? $owner->getId() : null;

                if ($newOwnerId !== $oldOwnerId) {
                    $this->authorize($entity, 'change-owner');
                    $owner = $newOwnerId
                        ? $this->getAdapter('users')->findEntity($newOwnerId)
                        : null;
                }
            }
        }
        if (!$owner instanceof User
            && Request::CREATE == $request->getOperation()
        ) {
            $owner = $this->getServiceLocator()
                ->get('Omeka\AuthenticationService')->getIdentity();
        }
        $entity->setOwner($owner);
    }

    /**
     * Hydrate the entity's resource class.
     *
     * Assumes the resource class can be set to NULL.
     *
     * @param Request $request
     * @param EntityInterface $entity
     */
    public function hydrateResourceClass(Request $request, EntityInterface $entity)
    {
        $data = $request->getContent();
        $resourceClass = $entity->getResourceClass();
        if ($this->shouldHydrate($request, 'o:resource_class')) {
            if (isset($data['o:resource_class']['o:id'])
                && is_numeric($data['o:resource_class']['o:id'])
            ) {
                $resourceClass = $this->getAdapter('resource_classes')
                    ->findEntity($data['o:resource_class']['o:id']);
            } else {
                $resourceClass = null;
            }
        }
        $entity->setResourceClass($resourceClass);
    }

    /**
     * Hydrate the entity's resource template.
     *
     * Assumes the resource template can be set to NULL.
     *
     * @param Request $request
     * @param EntityInterface $entity
     */
    public function hydrateResourceTemplate(Request $request, EntityInterface $entity)
    {
        $data = $request->getContent();
        $resourceTemplate = $entity->getResourceTemplate();
        if ($this->shouldHydrate($request, 'o:resource_template')) {
            if (isset($data['o:resource_template']['o:id'])
                && is_numeric($data['o:resource_template']['o:id'])
            ) {
                $resourceTemplate = $this->getAdapter('resource_templates')
                    ->findEntity($data['o:resource_template']['o:id']);
            } else {
                $resourceTemplate = null;
            }
        }
        $entity->setResourceTemplate($resourceTemplate);
    }

    /**
     * Update created/modified timestamps as appropriate for a request.
     *
     * @param Request $request
     * @param EntityInterface $entity
     */
    public function updateTimestamps(Request $request, EntityInterface $entity)
    {
        if (Request::CREATE === $request->getOperation()) {
            $entity->setCreated(new DateTime('now'));
        }

        $entity->setModified(new DateTime('now'));
    }
}