Skip to content

Commit

Permalink
Merge pull request #1 from carsdotcom/initial-commit
Browse files Browse the repository at this point in the history
Initial Commit
  • Loading branch information
jwadhams authored Jul 12, 2023
2 parents 2333654 + ee57bb3 commit 8d37fc5
Show file tree
Hide file tree
Showing 34 changed files with 12,638 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
node_modules/
npm-debug.log
yarn-error.log
.idea/
.ackrc

# Laravel 4 specific
bootstrap/compiled.php
Expand Down
8 changes: 8 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"tabWidth": 4,
"printWidth": 120,
"singleQuote": true,
"bracketSpacing": true,
"trailingComma": "es5",
"phpVersion": "8.0"
}
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 Cars.com

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
# laravel-json-model
Json-backed models for Laravel
# Laravel Json Model

We really love Laravel as an ORM. But we have a part of our application that is not backed by a document store,
not a relational database. Json Models let us use the best parts of the Eloquent Models,
but instead of being backed by a row in a table, they're always serialized to JSON. (Which can include
being serialized to an attribute on a traditional Laravel Model!)

## Setup

For now, you will need to add the following for events to work properly.
1. In AppServiceProvider boot method: `JsonModel::setEventDispatcher($this->app['events']);`
2. In AppServiceProvider register method: `JsonModel::clearBootedModels();`
311 changes: 311 additions & 0 deletions app/CollectionOfJsonModels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
<?php

/**
* One stop shop to hydrate a collection where every item is a JsonModel
*/

declare(strict_types=1);

namespace Carsdotcom\LaravelJsonModel;

use Carsdotcom\JsonSchemaValidation\Exceptions\JsonSchemaValidationException;
use Carsdotcom\LaravelJsonModel\Exceptions\UniqueException;
use Carsdotcom\JsonSchemaValidation\SchemaValidator;
use Carsdotcom\JsonSchemaValidation\Contracts\CanValidate;
use Carsdotcom\LaravelJsonModel\Helpers\FriendlyClassName;
use Carsdotcom\LaravelJsonModel\Traits\HasLinkedData;
use DomainException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\Response;

/**
* Class CollectionOfJsonModels
* @package Carsdotcom\LaravelJsonModel
*/
class CollectionOfJsonModels extends Collection implements CanValidate
{
use HasLinkedData;

/** @var string
* When defining a HasJsonModelAttributes $jsonModelAttributes config
* Using this constant in the fourth position marks an attribute as
* a CollectionOfJsonModels
*/
public const IS_A = true;
public const NOT_A = false;

/** @var string Class name that each element will be hydrated as during ->fresh() */
protected $itemClass = '';

/** @var null|string Optional name of a primary key. If present, you can use ->find and ->push will overwrite existing */
protected $primaryKey = null;
/**
* This call is mandatory for any real use of this class
* But if you modify the constructor, you start getting failures
* in Collection methods that are written to return `new static`
* @param string $itemClass
* @return CollectionOfJsonModels
*/
public function setType(string $itemClass = null): self
{
if (!is_a($itemClass, JsonModel::class, true)) {
throw new \DomainException('CollectionOfJsonModels type must be a descendent of JsonModel');
}
$this->itemClass = $itemClass;

return $this;
}

/**
* @param string|null $key
* @return CollectionOfJsonModels
*/
public function setPrimaryKey(string $key = null): self
{
$this->primaryKey = $key;
return $this;
}

/**
* Pull data from the link, and fill items with hydrated, linked JsonModels
* @return self
*/
public function fresh(): self
{
return $this->fill($this->getLinkedData() ?: []);
}

/**
* @param iterable $items
* @return CollectionOfJsonModels
* @throws DomainException
*/
public function fill(iterable $items): self
{
if (!$this->itemClass) {
throw new DomainException("Can't load CollectionOfJsonModels until type has been set.");
}

$this->items = [];

foreach ($items as $idx => $item) {
if (!($item instanceof $this->itemClass)) {
$item = new $this->itemClass($item);
}
$item->exists = true;
if ($this->primaryKey) {
$this->items[$item[$this->primaryKey]] = $item;
} else {
$this->items[] = $item;
}
}
$this->reindexItemLinks();

return $this;
}

/**
* Link all items individually with correct numeric indices
*/
protected function reindexItemLinks(): void
{
if (!$this->isLinked()) {
return;
}
foreach ($this->items as $idx => $item) {
$item->link(
$this->upstream_model,
$this->upstream_attribute,
$this->upstream_key ? "{$this->upstream_key}.{$idx}" : "{$idx}",
);
}
}

/**
* Push an item onto the end of the Collection.
* Our implementation uses offsetSet (like Laravel 5) to get casting and unique primary keys
* @param mixed $values [optional]
* @return CollectionOfJsonModels
*/
public function push(...$values)
{
foreach ($values as $value) {
$this->offsetSet(null, $value);
}
return $this;
}

/**
* Override the ArrayAccess method for setting (offsetSet) to:
* cast incoming items (especially arrays) to itemClass
* Check that you're not duplicating primaryKey
* (on implementations that require one)
* @param $key
* @param mixed $value
* @return void
*/
public function offsetSet($key, $value): void
{
if (!$this->itemClass) {
throw new \DomainException("Can't add items to CollectionOfJsonModels until type has been set.");
}
if (!$value instanceof $this->itemClass) {
if (!is_array($value)) {
$differentObject = is_object($value) ? get_class($value) : gettype($value);
throw new \DomainException("Can't insert a {$differentObject} in a Collection of {$this->itemClass}");
}

$value = new $this->itemClass($value);
}

// If ->push (key null) and has primaryKey, make sure you're not duplicating
if ($key === null && $this->primaryKey) {
if (isset($this->items[$value[$this->primaryKey]])) {
throw new UniqueException(
"Collection can't contain duplicate {$this->primaryKey} {$value[$this->primaryKey]}",
);
}
$this->items[$value[$this->primaryKey]] = $value;
} else {
parent::offsetSet($key, $value);
}
$this->reindexItemLinks();
}

/**
* Save the data over the link
* @return bool
*/
public function save(): bool
{
if ($this->preSave() === false) {
return false;
}

$this->setLinkedData();
$saved = $this->upstream_model->save();

if ($saved) {
$this->postSave();
}

return $saved;
}

/**
* Does this object pass its own standard for validation?
* @return true
* @throws JsonSchemaValidationException if data is invalid
*/
public function validateOrThrow(
string $exceptionMessage = null,
int $failureHttpStatusCode = Response::HTTP_BAD_REQUEST,
): bool {
if (!$this->itemClass) {
throw new \DomainException("Can't validate a CollectionOfJsonModels until type has been set.");
}
$absoluteCollectionSchemaUri = SchemaValidator::registerRawSchema(
json_encode([
'type' => 'array',
'items' => [
'$ref' => $this->itemClass::SCHEMA,
],
]),
);

return SchemaValidator::validateOrThrow(
$this,
$absoluteCollectionSchemaUri,
(new FriendlyClassName())(static::class) . ' contains invalid data!',
failureHttpStatusCode: $failureHttpStatusCode,
);
}

/**
* Convert the object into something JSON serializable.
* When we serialize as numeric for the wire,
* But when we're going to disk we serialize it as an object keyed by primary key
*
* @return array
*/
public function jsonSerialize(): array
{
$items_was = $this->items;
$this->items = array_values($this->items);
$serializedNumeric = parent::jsonSerialize();
$this->items = $items_was;
return $serializedNumeric;
}

/**
* Given a value, return the first element where primaryKey is that value
* or null if not found
* @param mixed $value
* @return JsonModel|null
*/
public function find($value): ?JsonModel
{
if (!$this->primaryKey) {
throw new \DomainException('Cannot use method find until primary key has been set.');
}
return $this[$value] ?? null;
}

/**
* Given a value, return the first element where primaryKey is that value
* @throws ModelNotFoundException if no element is found
* @param $value
* @return JsonModel
*/
public function findOrFail($value): JsonModel
{
$found = $this->find($value);
if (!$found) {
throw (new ModelNotFoundException())->setModel($this->itemClass ?: JsonModel::class);
}
return $found;
}

/**
* Call preSave observers on all items
* If *any* item returns false, exit early returning false.
* Otherwise return true
* @return bool
*/
public function preSave(): bool
{
return $this->every->preSave();
}

/**
* Call postSave observers on all items. Responses are ignored.
*/
public function postSave(): void
{
$this->each->postSave();
}

/**
* If you try to buildExpandedObject a CollectionOfJsonModels,
* make sure the children support it,
* then expand all the children with the requested attributes.
* @param array $with
* @return CollectionOfJsonModels
*/
public function buildExpandedObject(array $with): self
{
if (!method_exists($this->itemClass, 'buildExpandedObject')) {
throw new \DomainException(
(new FriendlyClassName())($this) .
' cannot buildExpandedObject because ' .
(new FriendlyClassName())($this->itemClass) .
' does not support it.',
);
}
foreach ($this->items as $item) {
$item->buildExpandedObject($with);
}
return $this;
}
}
28 changes: 28 additions & 0 deletions app/Contracts/CanCascadeEvents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/**
* If you can have children who have observers (e.g. a JsonModel with JsonModelAttributes,
* or a CollectionOfJsonModels) this interface provides clear methods to tell your
* children to fire their observers.
*/

namespace Carsdotcom\LaravelJsonModel\Contracts;

/**
* Interface CanCascadeEvents
* @package Carsdotcom\LaravelJsonModel\Contracts
*/
interface CanCascadeEvents
{
/**
* Tell all children of this object to execute preSave observers.
* If *any* of them fail, return a false. Otherwise return true.
* @return bool
*/
public function preSave(): bool;

/**
* Tell all children of this object to execute postSave observers.
*/
public function postSave(): void;
}
Loading

0 comments on commit 8d37fc5

Please sign in to comment.