<?php

namespace ChrisKonnertz\DeepLy\ResponseBag;

/**
 * This class handles the response content of a successful API translation call.
 * It checks its validity and offers methods to access the contents of the response content.
 * It implements an abstraction layer above the original response of the API call.
 */
class TranslationBag extends AbstractBag
{

    /**
     * Array with key-value-pairs that map sentences of the translation to paragraphs
     *
     * @var int[]
     */
    protected $lineBreaks;

    /**
     * SentencesBag constructor.
     *
     * @param \stdClass  $responseContent  The response content (payload) of a split text API call
     * @param int[]      $lineBreaks       Array of integers. The value is the index of a sentence in the
     *                                     $this->responseContent->translations array
     * @throws BagException
     */
    public function __construct(\stdClass $responseContent, $lineBreaks = array())
    {
        parent::__construct($responseContent);

        foreach ($lineBreaks as $sentenceIndex) {
            if (! is_int($sentenceIndex)) {
                throw new \InvalidArgumentException('$lineBreaks has to be an array with integer values', 200);
            }
            if ($sentenceIndex < 0) {
                throw new \InvalidArgumentException('$lineBreaks has to be an array with integer values >= 0', 201);
            }
        }

        $this->lineBreaks = $lineBreaks;
    }

    /**
     * Verifies that the given response content (usually a \stdClass built by json_decode())
     * is a valid result from an API call to the DeepL API.
     * This method will not return true/false but throw an exception if something is invalid.
     * Especially it will throw an exception if the API was not able to auto-detected the source language
     * (if no language code was given).
     *
     * @param mixed|null $responseContent The response content (payload) of a translation API call
     * @throws BagException
     * @return void
     */
    public function verifyResponseContent($responseContent)
    {
        // Let the original method of the abstract base class do some basic checks
        parent::verifyResponseContent($responseContent);

        if (! property_exists($responseContent, 'source_lang')) {
            throw new BagException(
                'DeepLy API call resulted in a malformed result - source_lang attribute is missing', 210
            );
        }
        if ($responseContent->source_lang === '') {
            throw new BagException('DeepL could not auto-detect the source language of the text', 211);
        }
        if (! property_exists($responseContent, 'target_lang')) {
            throw new BagException(
                'DeepLy API call resulted in a malformed result - target_lang attribute is missing', 220
            );
        }

        if (! property_exists($responseContent, 'translations')) {
            throw new BagException(
                'DeepLy API call resulted in a malformed result - translations are missing', 230
            );
        }
        if (! is_array($responseContent->translations)) {
            throw new BagException(
                'DeepLy API call resulted in a malformed result - translations are not an array', 231
            );
        }

        if (sizeof($responseContent->translations) > 0) {
            foreach ($responseContent->translations as $index => $translation) {
                if (! property_exists($translation, 'beams')) {
                    throw new BagException(
                        'DeepLy API call resulted in a malformed result - beams are missing for translation '.$index,
                        240
                    );
                }
                if (! is_array($translation->beams)) {
                    throw new BagException(
                        'DeepLy API call resulted in a malformed result - beams are not an array in translation '.
                        $index,
                        241
                    );
                }
                if (sizeof($translation->beams) == 0) {
                    throw new BagException(
                        'DeepLy API call resulted in a malformed result - beams array is empty in translation '.$index,
                        242
                    );
                }

                foreach ($translation->beams as $beamIndex => $beam) {
                    if (! property_exists($beam, 'postprocessed_sentence')) {
                        throw new BagException(
                            'DeepLy API call resulted in a malformed result - '.
                            'postprocessed_sentence property is missing in beam '.$index,
                            250
                        );
                    }
                }
            }
        }
    }

    /**
     * Returns a translation from the response content of the API call.
     * Tries to return the "best" translation (which is the first).
     * Returns null if there is no translation.
     *
     * @return string|null
     */
    public function getTranslation()
    {
        if (sizeof($this->responseContent->translations) === 0) {
            return null;
        }

        $translatedText = '';
        $lineBreaks = $this->lineBreaks; // Copy the array so we can modify it
        foreach ($this->responseContent->translations as $index => $translation) {
            // The beams array contains 1-n translation alternatives.
            // The first one (index 0) is the "best" one (best score)
            $beam = $translation->beams[0];

            // Add a line break. Add multiple line breaks if there is a cluster of empty lines.
            while (sizeof($lineBreaks) > 0 and current($lineBreaks) == $index) {
                array_shift($lineBreaks);
                $translatedText .= PHP_EOL;
            }

            // Since we want to return one string we have to glue the sentences.
            // We add a space between them if there is not space or line break at the end of the last sentence.
            // Note that we can use \n here, because PHP_EOL always ends with a \n.
            $lastChar = mb_substr($translatedText, -1);
            if ($lastChar !== '' and $lastChar !== ' ' and mb_substr($translatedText, -1) !== "\n") {
                $translatedText .= ' ';
            }

            $translatedText .= $beam->postprocessed_sentence;
        }

        return $translatedText;
    }

    /**
     * Returns the translation alternatives for a single translation / sentence
     * as a string array. Might return an empty array.
     *
     * @return string[]
     */
    public function getTranslationAlternatives()
    {
        if (sizeof($this->responseContent->translations) === 0) {
            return [];
        }

        if (sizeof($this->responseContent->translations) > 1) {
            throw new \LogicException('This method does not operate on more than one source text', 290);
        }

        $beams = $this->responseContent->translations[0]->beams;

        $translationAlternatives = [];
        foreach ($beams as $beam) {
            $translationAlternatives[] = $beam->postprocessed_sentence;
        }

        return $translationAlternatives;
    }

    /**
     * Returns a string array (might be empty) with all translation texts.
     * Every item in the result array will be one sentence.
     *
     * @return string[]
     */
    public function getTranslatedSentences()
    {
        $translatedTexts = [];

        foreach ($this->responseContent->translations as $translation) {
            // The beams array contains 1-n translation alternatives.
            // The first one (index 0) is the "best" one (best score)
            $translatedTexts[] = $translation->beams[0]->postprocessed_sentence;
        }

        return $translatedTexts;
    }

    /**
     * Returns the language code of the source ("from") language. Might have been auto-detected by DeepL.
     * Attention: DeepLy does not check if the result is in the Deeply::LANG_CODES array.
     * Therefore DeepLy also will work if DeepL adds support for new languages.
     *
     * @return string The language code, one of these: Deeply::LANG_CODES
     */
    public function getSourceLanguage()
    {
        return $this->responseContent->source_lang;
    }

    /**
     * Returns the language code of the target ("to") language.
     *
     * @return string The language code, one of these: Deeply::LANG_CODES
     */
    public function getTargetLanguage()
    {
        return $this->responseContent->target_lang;
    }

}


<?php

namespace ChrisKonnertz\DeepLy\HttpClient;

use ChrisKonnertz\DeepLy\Protocol\ProtocolInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Stream;

/**
 * This class uses cURL to execute API calls.
 */
class GuzzleHttpClient extends CurlHttpClient
{

    /**
     * The Guzzle instance
     *
     * @var Client
     */
    protected $guzzle;

    /**
     * GuzzleHttpClient constructor.
     *
     * @param ProtocolInterface $protocol
     */
    public function __construct(ProtocolInterface $protocol)
    {
        parent::__construct($protocol);

        if (! $this->isGuzzleAvailable()) {
            throw new \LogicException(
                'Cannot create instance of Guzzle, because it is not available. '.
                'It is not installed or the autoloading is not working'
            );
        }

        $this->guzzle = new Client();
    }

    /**
     * Executes an API call (a request) and returns the raw response data
     *
     * @param  string $url     The URL of the API endpoint
     * @param  array  $payload The payload of the request. Will be encoded as JSON
     * @param  string $method  The name of the method of the API call
     * @return string          The raw response data as string (usually contains stringified JSON)
     * @throws CallException   Throws a call exception if the call could not be executed
     */
    public function callApi($url, array $payload, $method)
    {
        if (! is_string($url)) {
            throw new \InvalidArgumentException('$url has to be a string');
        }
        if (! is_string($method)) {
            throw new \InvalidArgumentException('$method has to be a string');
        }

        $jsonData = $this->protocol->createRequestData($payload, $method);

        $options = ['body' => $jsonData];

        try {
            $guzzleResponse = $this->guzzle->post($url, $options);
        } catch (GuzzleException $exception) {
            $callException = new CallException('cURL error during DeepLy API call: '.$exception->getMessage());
            throw $callException;
        }

        $code = $guzzleResponse->getStatusCode();
        if ($code !== 200) {
            // Note that the response probably will contain an error description wrapped in a HTML page
            throw new CallException('Server side error during DeepLy API call: HTTP code '.$code);
        }

        if ($guzzleResponse->getBody() instanceof Stream) {
            $rawResponseData = $guzzleResponse->getBody()->getContents();
        } else {
            // This should never happen
            throw new CallException('$guzzleResponse->getBody() did not return a Stream object');
        }

        return $rawResponseData;
    }

    /**
     * Pings the API server. Returns the duration in seconds
     * or throws an exception if no valid response was received.
     *
     * @param string $url The URL of the API endpoint
     * @return float
     * @throws CallException
     */
    public function ping($url)
    {
        if (! is_string($url)) {
            throw new \InvalidArgumentException('$url has to be a string');
        }

        try {
            $startedAt = microtime(true);
            $this->guzzle->get($url);
            $duration = microtime(true) - $startedAt;
        } catch (GuzzleException $exception) {
            throw new CallException('Did not get a valid response. API seems to be unreachable.');
        }

        return $duration;
    }

    /**
     * Returns true if the Guzzle client is available via auto-loading
     *
     * @return bool
     */
    public function isGuzzleAvailable()
    {
        return class_exists(Client::class);
    }

    /**
     * Getter for the Guzzle object
     *
     * @return Client
     */
    public function getGuzzle()
    {
        return $this->guzzle;
    }

    /**
     * Setter for the Guzzle object
     *
     * @param Client $guzzle
     */
    public function setGuzzle(Client $guzzle)
    {
        $this->guzzle = $guzzle;
    }

}


<?php

namespace ChrisKonnertz\DeepLy\Protocol;

/**
 * JSON RPC is a remote procedure call protocol that uses JSON to encode data.
 * This class represents this protocol.
 *
 * Note: This class does not support batch requests/responses
 *
 * @see https://en.wikipedia.org/wiki/JSON-RPC (Wikipedia article)
 * @see http://www.jsonrpc.org/specification (Official specification)
 */
class JsonRpcProtocol implements ProtocolInterface
{

    /**
     * The number of the supported protocol version
     */
    const PROTOCOL_VERSION = '2.0';

    /**
     * Validate the ID of the response?
     * JSON RPC supports to add an ID to each request/response
     * that can be used to identify a request/response pair.
     *
     * @var bool
     */
    protected $validateId = true;

    /**
     * ID of the last JSON RPC request, int >= 0 (> 0 if there was a request)
     * We use a static var here so multiple instances of this class should not interfere.
     *
     * @var int
     */
    protected static $lastId = 0;

    /**
     * Creates a request bag according to the JSON RPC protocol.
     * The API will be able to understand it.
     * The result is encoded as a JSON string.
     *
     * @param array  $payload The payload / parameters of the request. Will be encoded as JSON
     * @param string $method  The method of the API call.
     * @return string
     */
    public function createRequestData(array $payload, $method)
    {
        if (! is_string($method)) {
            throw new \InvalidArgumentException('The $method argument has to be null or of type string');
        }

        // Every JSON RPC request has a unique ID so that the request and its response can be linked.
        // WARNING: There is no absolute guarantee that this ID is unique!
        // Use this class like a singleton to ensure uniqueness.
        // Note: According to the specs of the JSON RPC protocol we can send an int or a string,
        // so uniqid() - which returns a string - should work. Unfortunately it does not.
        self::$lastId = ++self::$lastId;
        $id = self::$lastId;

        $data = [
            'jsonrpc' => self::PROTOCOL_VERSION, // Set the protocol version
            'method'  => $method, // Set the method of the JSON RPC API call
            'params'  => $payload, // Set the parameters / the payload
            'id'      => $id, // Set the ID of this request. (Omitting it would mean we do not expect a response)
        ];

        $jsonData = json_encode($data);

        return $jsonData;
    }

    /**
     * Processes the data from an response from the server to an API call.
     * Returns the payload (data) of the response or throws a ProtocolException.
     *
     * @param string $rawResponseData The data (payload) of the response as a stringified JSON string
     * @return \stdClass              The data (payload) of the response as an object structure
     * @throws ProtocolException|\InvalidArgumentException
     */
    public function processResponseData($rawResponseData)
    {
        if (! is_string($rawResponseData)) {
            throw new \InvalidArgumentException('The $rawResponseData argument has to be a string');
        }

        $responseData = json_decode($rawResponseData);

        $this->validateResponseData($responseData);

        // We only return the inner result (=content) object, the other properties are no longer important
        return $responseData->result;
    }

    /**
     * Validates that the response data (usually a \stdClass object built by json_decode)
     * is valid response data from an API call to the DeepL API using the JSON RPC protocol.
     *
     * @param mixed $responseData The response data, usually a \stdClass object
     * @throws ProtocolException
     */
    protected function validateResponseData($responseData)
    {
        if (! $responseData instanceof \stdClass) {
            throw new ProtocolException('DeepLy API call did not return JSON that describes a \stdClass object');
        }

        if (! property_exists($responseData, 'jsonrpc')) {
            throw new ProtocolException('
                The given response data does not seem to be come from a JSON RPC request - it has no "jsonrpc" property'
            );
        }
        if ($responseData->jsonrpc !== self::PROTOCOL_VERSION) {
            throw new ProtocolException(
                'The version of the JSON RPC response does not match the expected version '.self::PROTOCOL_VERSION
            );
        }

        if (property_exists($responseData, 'error')) {
            if ($responseData->error instanceof \stdClass and property_exists($responseData->error, 'message')) {
                $message = 'DeepLy API call resulted in this error: '.$responseData->error->message;

                // Note: According to the specs the error object can include a data property,
                // but the DeepL API (usually) does not seem to add it. We will completely ignore it.
                if (property_exists($responseData->error, 'code') and is_int($responseData->error->code)) {
                    // Note: The meanings of the codes are defined in the protocol specification
                    throw new ProtocolException($message, $responseData->error->code);
                } else {
                    throw new ProtocolException($message);
                }
            } else {
                throw new ProtocolException('DeepLy API call resulted in an unknown error');
            }
        }

        if ($this->validateId) {
            if (! property_exists($responseData, 'id')) {
                throw new ProtocolException('DeepLy API call resulted in a malformed result - ID property is missing');
            }
            if ($responseData->id !== self::$lastId) {
                throw new ProtocolException('DeepLy API call resulted in an invalid result - ID is not '.self::$lastId);
            }
        }

        if (! property_exists($responseData, 'result')) {
            throw new ProtocolException(
                'DeepLy API call resulted in a malformed result - inner result property is missing'
            );
        }
        if (! $responseData->result instanceof \stdClass) {
            // If we try to translate an empty string the API responses with a malformed generic error response.
            // Due to the nature of the response we do not know for sure what has caused it.
            throw new ProtocolException(
                'DeepLy API call resulted in a malformed result - inner result property is not a \stdClass. '.
                'A possible cause is that the API could not translate the text, for example if it is an empty string'
            );
        }
    }

    /**
     * Validate the ID of the response?
     *
     * @return bool
     */
    public function getValidateId()
    {
        return $this->validateId;
    }

    /**
     * Setter for the validateId property
     *
     * @param bool $validateId
     */
    public function setValidateId($validateId)
    {
        if (! is_bool($validateId)) {
            throw new \InvalidArgumentException('The $validateId argument must be a boolean');
        }

        $this->validateId = $validateId;
    }

}


<?php

namespace ChrisKonnertz\DeepLy\HttpClient;

use ChrisKonnertz\DeepLy\Protocol\ProtocolInterface;

/**
 * This class uses cURL to execute API calls.
 */
class CurlHttpClient implements HttpClientInterface
{

    /**
     * Set this to false if you do not want cURL to
     * try to verify the SSL certificate - it could fail
     *
     * @see https://snippets.webaware.com.au/howto/stop-turning-off-curlopt_ssl_verifypeer-and-fix-your-php-config/
     *
     * @var bool
     */
    protected $sslVerifyPeer = true;

    /**
     * The protocol object that represents the protocol used for communication with the API
     *
     * @var ProtocolInterface
     */
    protected $protocol;

    /**
     * CurlHttpClient constructor.
     *
     * @param ProtocolInterface $protocol
     */
    public function __construct(ProtocolInterface $protocol)
    {
        if (! $this->isCurlAvailable()) {
            throw new \LogicException(
                'Cannot create instance of DeepLy\'s CurlHttpClient class, because the cURL PHP extension is not available'
            );
        }

        $this->protocol = $protocol;
    }

    /**
     * Executes an API call (a request) and returns the raw response data
     *
     * @param  string $url     The URL of the API endpoint
     * @param  array  $payload The payload of the request. Will be encoded as JSON
     * @param  string $method  The name of the method of the API call
     * @return string          The raw response data as string (usually contains stringified JSON)
     * @throws CallException   Throws a call exception if the call could not be executed
     */
    public function callApi($url, array $payload, $method)
    {
        if (! is_string($url)) {
            throw new \InvalidArgumentException('$url has to be a string');
        }
        if (! is_string($method)) {
            throw new \InvalidArgumentException('$method has to be a string');
        }

        $jsonData = $this->protocol->createRequestData($payload, $method);

        $curl = curl_init($url);

        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST');
        curl_setopt($curl, CURLOPT_POSTFIELDS, $jsonData);
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $this->sslVerifyPeer);

        // Note: We assume that we always will use JSON to encode data
        // so this is independent from the protocol that we actually use
        curl_setopt($curl, CURLOPT_HTTPHEADER, array(
            'Content-Type: application/json',
            'Content-Length: ' . strlen($jsonData)) // Note: We do not need mb_strlen here since JSON encodes Unicode
        );

        $rawResponseData = curl_exec($curl);

        if ($rawResponseData === false) {
            $exception = new CallException('cURL error during DeepLy API call: '.curl_error($curl));
            throw $exception;
        }

        $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        if ($code !== 200) {
            // Note that the response probably will contain an error description wrapped in a HTML page.
            // We extract the text and display add it tot he exception message.
            $text = $this->getTextFromHtml($rawResponseData);
            if ($text !== null) {
                $text = ', message: "'.$text.'"';
            }

            throw new CallException('Server side error during DeepLy API call: HTTP code '.$code.$text);
        }

        curl_close($curl);

        return $rawResponseData;
    }

    /**
     * Returns the text from an HTML document (passed as an HTML code string).
     * The text ist trimmed and line breaks are replaced by dashes.
     * Returns null if extracting the text was not possible.
     *
     * @param string $htmlCode
     * @return string|null
     */
    protected function getTextFromHtml($htmlCode)
    {
        $document = new \DOMDocument();

        $okay = $document->loadHTML($htmlCode);

        // Cannot load HTML document, not a valid HTML document
        if (!$okay) {
            return null;
        }

        $bodyElements = $document->getElementsByTagName('body');

        // Cannot find body-Element, not a valid HTML-Document
        if (sizeof($bodyElements) != 1) {
            return null;
        }

        /** @var \DOMElement $bodyElement */
        $bodyElement = $bodyElements[0];
        $text = $bodyElement->nodeValue;

        // Notes:
        // - It is not necessary to use some kind of "mb_trim()" function since trim() works with unicode chars
        // - We know that the server will add \n chars but no \r chars
        $text = str_replace("\n", ' - ', trim($text));

        return $text;
    }

    /**
     * Pings the API server. Returns the duration in seconds
     * or throws an exception if no valid response was received.
     *
     * @param string $url The URL of the API endpoint
     * @return float
     * @throws CallException
     */
    public function ping($url)
    {
        if (! is_string($url)) {
            throw new \InvalidArgumentException('$url has to be a string');
        }

        $curl = curl_init($url);

        // Do not "include the header in the output" (from the docs).
        // Should make the response a little bit smaller.
        curl_setopt($curl, CURLOPT_HEADER, 0);
        // Set this to true, because if it is set to false, curl will echo the result!
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);

        $startedAt = microtime(true);
        $result = curl_exec($curl);
        $duration = microtime(true) - $startedAt;

        curl_close($curl);

        if ($result === false) {
            throw new CallException('Did not get a valid response. API seems to be unreachable.');
        }

        return $duration;
    }

    /**
     * Getter for the sslVerifyPeer property
     *
     * @return bool
     */
    public function getSslVerifyPeer()
    {
        return $this->sslVerifyPeer;
    }

    /**
     * Setter for the sslVerifyPeer property
     *
     * @param bool $sslVerifyPeer
     */
    public function setSslVerifyPeer($sslVerifyPeer)
    {
        if (! is_bool($sslVerifyPeer)) {
            throw new \InvalidArgumentException('$sslVerifyPeer has to be boolean');
        }

        $this->sslVerifyPeer = $sslVerifyPeer;
    }

    /**
     * Returns true if the cURL extension is available, false otherwise
     *
     * @return bool
     */
    public function isCurlAvailable()
    {
        return (in_array('curl', get_loaded_extensions()));
    }

    /**
     * Getter for the protocol object
     *
     * @return ProtocolInterface
     */
    public function getProtocol()
    {
        return $this->protocol;
    }

    /**
     * Setter for the protocol object
     *
     * @param ProtocolInterface $protocol
     */
    public function setProtocol($protocol)
    {
        $this->protocol = $protocol;
    }

}


<?php

namespace ChrisKonnertz\DeepLy\ResponseBag;

/**
 * This class handles the response content of a successful API split text call.
 * It checks its validity and offers methods to access the contents of the response content.
 * It implements an abstraction layer above the original response of the API call.
 */
class SentencesBag extends AbstractBag
{

    /**
     * Verifies that the given response content (usually a \stdClass built by json_decode())
     * is a valid result from an API call to the DeepL API.
     * This method will not return true/false but throw an exception if something is invalid.
     * Especially it will throw an exception if the API was not able to auto-detected the language
     * (if no language code was given).
     *
     * @param mixed|null $responseContent The response content (payload) of a split text API call
     * @throws BagException
     * @return void
     */
    public function verifyResponseContent($responseContent)
    {
        // Let the original method of the abstract base class do some basic checks
        parent::verifyResponseContent($responseContent);

        if (! property_exists($responseContent, 'splitted_texts')) {
            throw new BagException(
                'DeepLy API call resulted in a malformed result - splitted_texts property is missing', 100
            );
        }
        if (! is_array($responseContent->splitted_texts)) {
            throw new BagException(
                'DeepLy API call resulted in a malformed result - splitted_texts property is not an array', 101
            );
        }
        if (! property_exists($responseContent, 'lang')) {
            throw new BagException('DeepLy API call resulted in a malformed result - lang property is missing', 120);
        }
        if ($responseContent->lang === '') {
            throw new BagException('DeepL could not auto-detect the language of the text', 130);
        }
    }

    /**
     * Returns a string array with all sentences from the response.
     *
     * @return string[]
     */
    public function getAllSentences()
    {
        $splitTexts = $this->responseContent->splitted_texts;

        $sentences = [];

        foreach ($splitTexts as $splitText) {
            foreach ($splitText as $sentence) {
                $sentences[] = $sentence;
            }
        }

        return $sentences;
    }

    /**
     * Returns all sentences from the response, but the groups are kept,
     * so this method returns an array of array of strings.
     *
     * @return string[][]
     */
    public function getAllSentencesGrouped()
    {
        return $this->responseContent->splitted_texts;
    }

    /**
     * Returns the language code of the sentences. This is useful when the API auto-detected the language.
     *
     * @return string A DeepLy::LANG_<code> constant
     */
    public function getLanguage()
    {
        return $this->responseContent->lang;
    }

}


<?php

namespace ChrisKonnertz\DeepLy;

use ChrisKonnertz\DeepLy\HttpClient\CallException;
use ChrisKonnertz\DeepLy\HttpClient\HttpClientInterface;
use ChrisKonnertz\DeepLy\HttpClient\CurlHttpClient;
use ChrisKonnertz\DeepLy\Protocol\JsonRpcProtocol;
use ChrisKonnertz\DeepLy\Protocol\ProtocolInterface;
use ChrisKonnertz\DeepLy\ResponseBag\SentencesBag;
use ChrisKonnertz\DeepLy\ResponseBag\TranslationBag;

/**
 * This is the main class. Call its translate() method to translate text.
 */
class DeepLy
{

    /**
     * All supported language code constants
     * @see https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
     */
    const LANG_AUTO = 'auto'; // Let DeepL decide which language it is (only works for the source language)
    const LANG_DE = 'DE'; // German
    const LANG_EN = 'EN'; // English
    const LANG_FR = 'FR'; // French
    const LANG_ES = 'ES'; // Spanish
    const LANG_IT = 'IT'; // Italian
    const LANG_NL = 'NL'; // Dutch
    const LANG_PL = 'PL'; // Polish
    const LANG_PT = 'PT'; // Portuguese
    const LANG_RU = 'RU'; // Russian

    /**
     * Array with all supported language codes
     * @see https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
     */
    const LANG_CODES = [
        self::LANG_AUTO,
        self::LANG_DE,
        self::LANG_EN,
        self::LANG_FR,
        self::LANG_ES,
        self::LANG_IT,
        self::LANG_NL,
        self::LANG_PL,
        self::LANG_PT,
        self::LANG_RU,
    ];

    /**
     * Array with language codes as keys and the matching language names in English as values
     * @see https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
     */
    const LANG_NAMES = [
        self::LANG_AUTO => 'Auto',
        self::LANG_DE => 'German',
        self::LANG_EN => 'English',
        self::LANG_FR => 'French',
        self::LANG_ES => 'Spanish',
        self::LANG_IT => 'Italian',
        self::LANG_NL => 'Dutch',
        self::LANG_PL => 'Polish',
        self::LANG_PT => 'Portuguese',
        self::LANG_RU => 'Russian',
    ];

    /**
     * The length of the text for translations is limited by the API
     */
    const MAX_TRANSLATION_TEXT_LEN = 5000;

    /**
     * Constants that are names of methods that can be called via the API
     */
    const METHOD_TRANSLATE = 'LMT_handle_jobs'; // Translates a text
    const METHOD_SPLIT = 'LMT_split_into_sentences'; // Splits a text into sentences

    /**
     * The base URL of the API endpoint
     */
    const API_BASE_URL = 'https://www2.deepl.com/jsonrpc/';

    /**
     * Current version number
     */
    const VERSION = '1.6.1';

    /**
     * If true, validate that the length of a translation text
     * is not greater than self::MAX_TRANSLATION_TEXT_LEN
     *
     * @var bool
     */
    protected $validateTextLength = true;

    /**
     * The protocol used for communication
     *
     * @var ProtocolInterface
     */
    protected $protocol;

    /**
     * The HTTP client used for communication
     *
     * @var HttpClientInterface
     */
    protected $httpClient;

    /**
     * This property stores the result (object) of the last translation
     *
     * @var TranslationBag|null
     */
    protected $translationBag = null;

    /**
     * DeepLy object constructor.
     */
    public function __construct()
    {
        // Create the default protocol object. You may call setProtocol() to switch it.
        $this->protocol = new JsonRpcProtocol();

        // Create the default HTTP client. You may call setHttpClient() to set another HTTP client.
        $this->httpClient = new CurlHttpClient($this->protocol);
    }

    /**
     * Uses the DeepL API to split a text into sentences. Stores them in a SentencesBag object and returns it.
     * You may use the splitText() method if you want to get the result as a string array.
     * Attention: If you choose to set $from to "auto", the API will not return a result if it is unable to
     * detect the language.
     *
     * @param string|string[] $text The text you want to split into sentences. It can be a single string or string array
     * @param string          $from Optional: The source language, a self::LANG_<code> constant
     * @return SentencesBag
     * @throws \Exception
     */
    public function requestSplitText($text, $from = self::LANG_AUTO)
    {
        if (! is_string($text) and ! is_array($text)) {
            throw new \InvalidArgumentException('The $text argument has to be a string or an array of strings');
        }
        if (is_array($text)) {
            foreach ($text as $index => $part) {
                if (! is_string($part)) {
                    throw new \InvalidArgumentException(
                        'If the $text argument is an array it ha to be an array of strings, but the '.($index + 1).
                        '. item is not a string'
                    );
                }
            }
        } else {
            $text = [$text];
        }

        $params = [
            'texts' => $text,
            'lang' => [
                'lang_user_selected' => $from
            ]
        ];

        $rawResponseData = $this->httpClient->callApi(self::API_BASE_URL, $params, self::METHOD_SPLIT);

        $responseContent = $this->protocol->processResponseData($rawResponseData);

        $splitTextBag = new SentencesBag($responseContent);

        return $splitTextBag;
    }

    /**
     * Uses the DeepL API to split a text into a string array of sentences.
     * This method might throw an exception so you should wrap it in a try-catch-block.
     *
     * @param string|string[] $text The text you want to split into sentences. It can be a single string or string array
     * @param string          $from Optional: The source language, a self::LANG_<code> constant
     * @return string[]
     * @throws \Exception
     */
    public function splitText($text, $from = self::LANG_AUTO)
    {
        $splitTextBag = $this->requestSplitText($text, $from);

        $sentences = $splitTextBag->getAllSentences();

        return $sentences;
    }

    /**
     * Tries to detect the language of a text and returns its language code.
     * The language of the text has to be one of the supported languages or the result will be incorrect.
     * This method might throw an exception so you should wrap it in a try-catch-block.
     * Especially it will throw an exception if the API was not able to auto-detected the language.
     *
     * @param string      $text The text you want to analyze
     * @return string|null      Returns a language code from the self::LANG_CODES array or null
     * @throws \Exception
     */
    public function detectLanguage($text)
    {
        // Note: We always use English as the target language. If the source language is English as well,
        // DeepL automatically seems to set the target language to French so this is not a problem.
        $translationBag = $this->requestTranslation($text, self::LANG_EN, self::LANG_AUTO);

        return $translationBag->getSourceLanguage();
    }

    /**
     * Requests a translation from the API. Returns a TranslationBag object.
     * ATTENTION: The target language parameter is followed by the source language parameter!
     * This method might throw an exception so you should wrap it in a try-catch-block.
     * You may use the translate() method if you want to get the result as a string.
     *
     * @param string|string[] $text           The text to translate. A single string or an array of sentences (strings)
     * @param string          $to             Optional: The target language, a self::LANG_<code> constant
     * @param string|null     $from           Optional: The source language, a self::LANG_<code> constant
     * @return TranslationBag
     * @throws \Exception
     */
    protected function requestTranslation($text, $to = self::LANG_EN, $from = self::LANG_AUTO)
    {
        $this->translationBag = null;

        if (! is_string($text) and ! is_array($text)) {
            throw new \InvalidArgumentException('The $text argument has to be a string or an array');
        }
        if (is_array($text)) {
            foreach ($text as $index => $sentence) {
                if (! is_string($sentence)) {
                    throw new \InvalidArgumentException(
                        'The $text argument has to be a string or an array of strings. '.
                        'The '.(++$index).'. item of the array is not a string.'
                    );
                }
                // TODO Ensure that the limit is per sentence
                if ($this->validateTextLength and mb_strlen($sentence) > self::MAX_TRANSLATION_TEXT_LEN) {
                    throw new \InvalidArgumentException(
                        'The '.(++$index).'. sentence exceeds the maximum of '.self::MAX_TRANSLATION_TEXT_LEN.' chars'
                    );
                }
            }
        } else {
            if ($this->validateTextLength and mb_strlen($text) > self::MAX_TRANSLATION_TEXT_LEN) {
                throw new \InvalidArgumentException(
                    'The text exceeds the maximum of '.self::MAX_TRANSLATION_TEXT_LEN.' chars'
                );
            }
        }
        if (! is_string($to)) {
            throw new \InvalidArgumentException('The $to argument has to be a string');
        }
        if (! in_array($to, self::LANG_CODES)) {
            throw new \InvalidArgumentException('The $to argument has to be a valid language code');
        }
        if ($to === self::LANG_AUTO) {
            throw new \InvalidArgumentException('The $to argument cannot be "'.self::LANG_AUTO.'"');
        }
        if (! is_string($from)) {
            throw new \InvalidArgumentException('The $from argument has to be a string');
        }
        if (! in_array($from, self::LANG_CODES)) {
            throw new \InvalidArgumentException('The $from argument has to a valid language code');
        }

        // Note that this array will be converted to a data structure of arrays AND objects later on
        $params = [
            'lang' => [
                'source_lang_user_selected' => $from, // Attention: source_lang does not work!
                'target_lang' => $to,
            ]
        ];

        if (is_array($text)) {
            $lines = $text;
            $sentences = [];

            foreach ($text as $sentence) {
                $sentences[] = [$sentence];
            }
        } else {
            // We try to auto-detect which is the right line break
            $lineBreak = "\r\n";
            if (strpos($text, $lineBreak) === false and strpos($text, "\n") !== false) {
                $lineBreak = "\n";
            }

            $lines = explode($lineBreak, $text);

            $sentencesBag = $this->requestSplitText($lines, $from);
            $sentences = $sentencesBag->getAllSentencesGrouped();
        }

        $params['jobs'] = [];
        $lineBreaks = [];
        foreach ($lines as $index => $line) {
            if ($index > 0) {
                $lineBreaks[] = sizeof($params['jobs']);
            }

            foreach ($sentences[$index] as $sentence) {
                $params['jobs'][] =  [
                    'kind' => 'default',
                    'raw_en_sentence' => $sentence,
                ];
            }
        }

        // The API call might throw an exception but we do not want to catch it,
        // instead the caller of this method has to catch it.
        $rawResponseData = $this->httpClient->callApi(self::API_BASE_URL, $params, self::METHOD_TRANSLATE);

        $responseContent = $this->protocol->processResponseData($rawResponseData);

        $translationBag = new TranslationBag($responseContent, $lineBreaks);

        $this->translationBag = $translationBag;

        return $translationBag;
    }

    /**
     * Translates a text.
     * ATTENTION: The target language parameter is followed by the source language parameter!
     * This method might throw an exception so you should wrap it in a try-catch-block.
     *
     * @param string      $text           The text you want to translate
     * @param string      $to             Optional: The target language, a self::LANG_<code> constant
     * @param string|null $from           Optional: The source language, a self::LANG_<code> constant
     * @return null|string                Returns the translated text or null if there is no translation
     */
    public function translate($text, $to = self::LANG_EN, $from = self::LANG_AUTO)
    {
        $translationBag = $this->requestTranslation($text, $to, $from);

        return $translationBag->getTranslation();
    }

    /**
     * Translates a short text / a sentence. Returns an array of translation proposals.
     * ATTENTION: The target language parameter is followed by the source language parameter!
     * This method might throw an exception so you should wrap it in a try-catch-block.
     *
     * @param string      $text The text you want to translate
     * @param string      $to   Optional: The target language, a self::LANG_<code> constant
     * @param string|null $from Optional: The source language, a self::LANG_<code> constant
     * @return string[]         Returns translation alternatives as a string array
     * @throws \Exception
     */
    public function proposeTranslations($text, $to = self::LANG_EN, $from = self::LANG_AUTO)
    {
        $translationBag = $this->requestTranslation($text, $to, $from);

        return $translationBag->getTranslationAlternatives();
    }

    /**
     * Translates a text. Returns a string array of translation sentences.
     * ATTENTION: The target language parameter is followed by the source language parameter!
     * This method might throw an exception so you should wrap it in a try-catch-block.
     *
     * @param string[]    $sentences The sentences you want to translate
     * @param string      $to        Optional: The target language, a self::LANG_<code> constant
     * @param string|null $from      Optional: The source language, a self::LANG_<code> constant
     * @param bool        $join      If true, join all sentences to a single string
     * @return \string[] Returns a string array (might be empty)
     */
    public function translateSentences(array $sentences, $to = self::LANG_EN, $from = self::LANG_AUTO, $join = false)
    {
        $translationBag = $this->requestTranslation($sentences, $to, $from);

        $translatedSentences = $translationBag->getTranslatedSentences();

        if ($join) {
            return implode(' ', $translatedSentences);
        }

        return $translatedSentences;
    }

    /**
     * Translates a text file. The $from argument is optional.
     * ATTENTION: The target language parameter is followed by the source language parameter!
     * This method will throw an exception if reading the file or translating fails
     * so you should wrap it in a try-catch-block.
     *
     * @param string      $filename The name of the file you want to translate
     * @param string      $to       Optional: The target language, a self::LANG_<code> constant
     * @param string|null $from     Optional: The source language, a self::LANG_<code> constant
     * @return string|null          Returns the translated text or null if there is no translation
     * @throws \Exception
     */
    public function translateFile($filename, $to = self::LANG_EN, $from = self::LANG_AUTO)
    {
        if (! is_string($filename)) {
            throw new \InvalidArgumentException('The $filename argument has to be a string');
        }
        if (! is_readable($filename)) {
            throw new \InvalidArgumentException('Could not read file with the given filename');
        }

        $text = file_get_contents($filename);

        if ($text === false) {
            throw new \RuntimeException(
                'Could not read file with the given filename. Does this file exist and do we have read permission?'
            );
        }

        return $this->translate($text, $to, $from);
    }

    /**
     * Pings the API server. Returns the duration in seconds until the response arrives
     * or throws an exception if no valid response was received.
     *
     * @return float
     * @throws CallException
     */
    public function ping()
    {
        return $this->httpClient->ping(self::API_BASE_URL);
    }

    /**
     * Setter for the validateTextLength property
     * If true, validate that the length of a translation text
     * is not greater than self::MAX_TRANSLATION_TEXT_LEN
     *
     * @return bool
     */
    public function getValidateTextLength()
    {
        return $this->validateTextLength;
    }

    /**
     * Getter for the validateTextLength property.
     * If true, validate that the length of a translation text
     * is not greater than self::MAX_TRANSLATION_TEXT_LEN
     *
     * @param bool $validateTextLength
     */
    public function setValidateTextLength($validateTextLength)
    {
        if (! is_bool($validateTextLength)) {
            throw new \InvalidArgumentException('$validateTextLength has to be boolean');
        }

        $this->validateTextLength = $validateTextLength;
    }

    /**
     * Getter for the protocol object
     *
     * @return ProtocolInterface
     */
    public function getProtocol()
    {
        return $this->protocol;
    }

    /**
     * Setter for the protocol object
     *
     * @param ProtocolInterface $protocol
     */
    public function setProtocol(ProtocolInterface $protocol)
    {
        $this->protocol = $protocol;
    }

    /**
     * Getter for the HTTP client object
     *
     * @return HttpClientInterface
     */
    public function getHttpClient()
    {
        return $this->httpClient;
    }

    /**
     * Setter for the HTTP client object. This allows you to use another HTTP client
     * than the default cURL based HTTP client.
     *
     * @param HttpClientInterface $httpClient
     */
    public function setHttpClient(HttpClientInterface $httpClient)
    {
        $this->httpClient = $httpClient;
    }

    /**
     * Decides if a language (code) is supported by DeepL(y).
     * Note that 'auto' is not a valid value in this context
     * except you explicitly set the $allowAuto param to true
     *
     * @param string $langCode  The language code, for example 'EN'
     * @param bool   $allowAuto Optional: If false, 'auto' is not a valid language
     * @return bool
     */
    public function supportsLangCode($langCode, $allowAuto = false)
    {
        if (! is_string($langCode)) {
            throw new \InvalidArgumentException('The $langCode argument has to be a string');
        }

        $supported = in_array($langCode, $this->getLangCodes($allowAuto));

        return $supported;
    }

    /**
     * Getter for the array with all supported language codes
     *
     * @param bool $withAuto Optional: If true, the 'auto' code will be in the returned array
     * @return \string[]
     */
    public function getLangCodes($withAuto = true)
    {
        if (! is_bool($withAuto)) {
            throw new \InvalidArgumentException('The $withAuto argument has to be boolean');
        }

        if ($withAuto) {
            return self::LANG_CODES;
        }

        // ATTENTION! This only works as long as self::LANG_AUTO is the first item!
        return array_slice(self::LANG_CODES, 1);
    }

    /**
     * Returns the English name of a language for a given language code.
     * The language code must be on of these: self::LANG_CODES
     *
     * @param string $langCode The code of the language
     * @return string
     */
    public function getLangName($langCode)
    {
        if (! in_array($langCode, self::LANG_CODES)) {
            throw new \InvalidArgumentException('The language code is unknown');
        }

        return self::LANG_NAMES[$langCode];
    }

    /**
     * Returns the language code of a language for a given language name.
     * The language name must be one of these: self::LANG_NAMES
     *
     * @param string $langName The name of the language
     * @return string
     */
    public function getLangCodeByName($langName)
    {
        if (! in_array($langName, self::LANG_NAMES)) {
            throw new \InvalidArgumentException('The language name is unknown');
        }

        return array_search($langName, self::LANG_NAMES);
    }

    /**
     * Getter for the TranslationBag object. Might return null!
     * The translation bag contains the result of the last API call.
     *
     * @return TranslationBag|null
     */
    public function getTranslationBag()
    {
        return $this->translationBag;
    }

}


<?php

namespace ChrisKonnertz\DeepLy\ResponseBag;

/**
 * This class is the abstract class for all bag classes.
 * A bag class handles the response content of a successful API call.
 * It checks its validity and offers methods to access the contents of the response content.
 * It implements an abstraction layer above the original response of the API call.
 */
abstract class AbstractBag
{

    /**
     * The response content (payload) object of a split text API call
     *
     * @var \stdClass
     */
    protected $responseContent;

    /**
     * SentencesBag constructor.
     *
     * @param \stdClass $responseContent The response content (payload) of a split text API call
     * @throws BagException
     */
    public function __construct(\stdClass $responseContent)
    {
        $this->verifyResponseContent($responseContent);

        $this->responseContent = $responseContent;
    }

    /**
     * Verifies that the given response content (usually a \stdClass built by json_decode())
     * is a valid result from an API call to the DeepL API.
     * This method will not return true/false but throw an exception if something is invalid.
     *
     * @param mixed|null $responseContent The response content (payload) of a split text API call
     * @throws BagException
     * @return void
     */
    public function verifyResponseContent($responseContent)
    {
        if (! $responseContent instanceof \stdClass) {
            throw new BagException('DeepLy API call did not return JSON that describes a \stdClass object', 10);
        }

        if (property_exists($responseContent, 'error')) {
            if ($responseContent->error instanceof \stdClass and property_exists($responseContent->error, 'message')) {
                throw new BagException(
                    'DeepLy API call resulted in this error: '.$responseContent->error->message, 20
                );
            } else {
                throw new BagException('DeepLy API call resulted in an unknown error', 21);
            }
        }
    }

    /**
     * Getter for the response content (payload) object of a split text API call
     *
     * @return \stdClass
     */
    public function getResponseContent()
    {
        return $this->responseContent;
    }

}


<?php
declare(strict_types = 1);

namespace SimpleCrud;

use ArrayAccess;
use Countable;
use Psr\EventDispatcher\EventDispatcherInterface;
use SimpleCrud\Events\BeforeCreateRow;
use SimpleCrud\Events\CreateDeleteQuery;
use SimpleCrud\Events\CreateInsertQuery;
use SimpleCrud\Events\CreateSelectQuery;
use SimpleCrud\Events\CreateUpdateQuery;
use SimpleCrud\Fields\Field;
use SimpleCrud\Queries\Delete;
use SimpleCrud\Queries\Insert;
use SimpleCrud\Queries\Select;
use SimpleCrud\Queries\SelectAggregate;
use SimpleCrud\Queries\Update;

/**
 * Manages a database table.
 *
 * @property Field $id
 */
class Table implements ArrayAccess, Countable
{
    private $name;
    private $db;
    private $cache = [];
    private $fields = [];
    private $defaults = [];
    private $eventDispatcher;

    protected const ROW_CLASS = Row::class;
    protected const ROWCOLLECTION_CLASS = RowCollection::class;

    final public function __construct(Database $db, string $name)
    {
        $this->db = $db;
        $this->name = $name;

        $fieldFactory = $db->getFieldFactory();
        $fields = $db->getScheme()->getTableFields($name);

        foreach ($fields as $info) {
            $field = $fieldFactory->get($this, $info);

            $this->fields[$field->getName()] = $field;
            $this->defaults[$field->getName()] = null;
        }
    }

    public function __debugInfo(): array
    {
        return [
            'name' => $this->name,
            'fields' => $this->fields,
        ];
    }

    public function __toString()
    {
        return "`{$this->name}`";
    }

    public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self
    {
        $this->eventDispatcher = $eventDispatcher;

        return $this;
    }

    public function getEventDispatcher(): ?EventDispatcherInterface
    {
        return $this->eventDispatcher;
    }

    /**
     * Get the devault values used in new rows
     */
    public function getDefaults(array $overrides = null): array
    {
        if (empty($overrides)) {
            return $this->defaults;
        }

        $diff = array_diff_key($overrides, $this->fields);

        if (!empty($diff)) {
            throw new SimpleCrudException(
                sprintf('The field %s does not exist in the table %s', implode(array_keys($diff)), $this)
            );
        }

        return $overrides + $this->defaults;
    }

    /**
     * Store a row in the cache.
     */
    public function cache(Row $row): Row
    {
        if ($row->id) {
            $this->cache[$row->id] = $row;
        }

        return $row;
    }

    /**
     * Clear the current cache.
     */
    public function clearCache(): self
    {
        $this->cache = [];

        return $this;
    }

    /**
     * Returns whether the id is cached or not
     * @param mixed $id
     */
    public function isCached($id): bool
    {
        return array_key_exists($id, $this->cache);
    }

    /**
     * Returns a row from the cache.
     * @param mixed $id
     */
    public function getCached($id): ?Row
    {
        if (!$this->isCached($id)) {
            return null;
        }

        $row = $this->cache[$id];

        if ($row && !$row->id) {
            return $this->cache[$id] = null;
        }

        return $row;
    }

    public function delete(): Delete
    {
        $query = new Delete($this);

        if ($eventDispatcher = $this->getEventDispatcher()) {
            $eventDispatcher->dispatch(new CreateDeleteQuery($query));
        }

        return $query;
    }

    public function insert(array $data = []): Insert
    {
        $query = new Insert($this, $data);

        if ($eventDispatcher = $this->getEventDispatcher()) {
            $eventDispatcher->dispatch(new CreateInsertQuery($query));
        }

        return $query;
    }

    public function select(): Select
    {
        $query = new Select($this);

        if ($eventDispatcher = $this->getEventDispatcher()) {
            $eventDispatcher->dispatch(new CreateSelectQuery($query));
        }

        return $query;
    }

    public function selectAggregate(string $function, string $field = 'id'): SelectAggregate
    {
        $query = new SelectAggregate($this, $function, $field);

        if ($eventDispatcher = $this->getEventDispatcher()) {
            $eventDispatcher->dispatch(new CreateSelectQuery($query));
        }

        return $query;
    }

    public function update(array $data = []): Update
    {
        $query = new Update($this, $data);

        if ($eventDispatcher = $this->getEventDispatcher()) {
            $eventDispatcher->dispatch(new CreateUpdateQuery($query));
        }

        return $query;
    }

    /**
     * Magic method to get the Field instance of a table field
     */
    public function __get(string $name): Field
    {
        if (!isset($this->fields[$name])) {
            throw new SimpleCrudException(
                sprintf('The field `%s` does not exist in the table %s', $name, $this)
            );
        }

        return $this->fields[$name];
    }

    /**
     * Magic method to check if a field exists or not.
     */
    public function __isset(string $name): bool
    {
        return isset($this->fields[$name]);
    }

    /**
     * @see Countable
     */
    public function count(): int
    {
        return $this->selectAggregate('COUNT')->run();
    }

    /**
     * Check if a row with a specific id exists.
     *
     * @see ArrayAccess
     * @param mixed $offset
     */
    public function offsetExists($offset): bool
    {
        if ($this->isCached($offset)) {
            return $this->getCached($offset) !== null;
        }

        return $this->selectAggregate('COUNT')
            ->where('id = ', $offset)
            ->limit(1)
            ->run() === 1;
    }

    /**
     * Returns a row with a specific id.
     *
     * @see ArrayAccess
     *
     * @param mixed $offset
     */
    public function offsetGet($offset): ?Row
    {
        if ($this->isCached($offset)) {
            return $this->getCached($offset);
        }

        return $this->cache[$offset] = $this->select()
            ->one()
            ->where('id = ', $offset)
            ->run();
    }

    /**
     * Store a row with a specific id.
     *
     * @see ArrayAccess
     * @param mixed $offset
     * @param mixed $value
     */
    public function offsetSet($offset, $value): Row
    {
        //Insert on missing offset
        if ($offset === null) {
            $value['id'] = null;

            return $this->create($value)->save();
        }

        //Update if the element is cached and exists
        $row = $this->getCached($offset);

        if ($row) {
            return $row->edit($value)->save();
        }

        //Update if the element it's not cached
        if (!$this->isCached($row)) {
            $this->update()
                ->columns($value)
                ->where('id = ', $offset)
                ->run();
        }
    }

    /**
     * Remove a row with a specific id.
     *
     * @see ArrayAccess
     * @param mixed $offset
     */
    public function offsetUnset($offset)
    {
        $this->cache[$offset] = null;

        $this->delete()
            ->where('id = ', $offset)
            ->run();
    }

    /**
     * Returns the table name.
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * Returns the foreign key name.
     */
    public function getForeignKey(): string
    {
        return "{$this->name}_id";
    }

    /**
     * Returns the foreign key.
     */
    public function getJoinField(Table $table): ?Field
    {
        $field = $table->getForeignKey();

        return $this->fields[$field] ?? null;
    }

    public function getJoinTable(Table $table): ?Table
    {
        $name1 = $this->getName();
        $name2 = $table->getName();
        $name = $name1 < $name2 ? "{$name1}_{$name2}" : "{$name2}_{$name1}";

        $joinTable = $this->db->{$name} ?? null;

        if ($joinTable && $joinTable->getJoinField($this) && $joinTable->getJoinField($table)) {
            return $joinTable;
        }

        return null;
    }

    /**
     * Returns the Database instance associated with this table.
     */
    public function getDatabase(): Database
    {
        return $this->db;
    }

    /**
     * Returns all fields.
     *
     * @return Field[]
     */
    public function getFields()
    {
        return $this->fields;
    }

    public function create(array $data = []): Row
    {
        if (isset($data['id']) && ($row = $this->getCached($data['id']))) {
            return $row;
        }

        $eventDispatcher = $this->getEventDispatcher();

        if ($eventDispatcher) {
            $event = new BeforeCreateRow($data);
            $eventDispatcher->dispatch($event);
            $data = $event->getData();
        }

        $class = self::ROW_CLASS;
        return $this->cache(new $class($this, $data));
    }

    public function createCollection(array $rows = []): RowCollection
    {
        $class = self::ROWCOLLECTION_CLASS;
        return new $class($this, ...$rows);
    }
}


<?php
declare(strict_types = 1);

namespace SimpleCrud;

use ArrayAccess;
use BadMethodCallException;
use Countable;
use Iterator;
use JsonSerializable;
use RuntimeException;
use SimpleCrud\Queries\Select;

/**
 * Stores a collection of rows.
 */
class RowCollection implements ArrayAccess, Iterator, Countable, JsonSerializable
{
    private $table;
    private $rows = [];
    private $data = [];

    public function __construct(Table $table, Row ...$rows)
    {
        $this->table = $table;

        foreach ($rows as $row) {
            $this->rows[$row->id] = $row;
        }
    }

    public function __debugInfo(): array
    {
        return [
            'table' => (string) $this->table,
            'rows' => $this->rows,
        ];
    }

    public function __call(string $name, array $arguments): Select
    {
        $db = $this->table->getDatabase();

        //Relations
        if (isset($db->$name)) {
            return $this->select($db->$name);
        }

        throw new BadMethodCallException(
            sprintf('Invalid method call %s', $name)
        );
    }

    public function setData(array $data): self
    {
        $this->data = $data + $this->data;

        return $this;
    }

    /**
     * @see JsonSerializable
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }

    /**
     * Magic method to stringify the values.
     */
    public function __toString()
    {
        return json_encode($this, JSON_NUMERIC_CHECK);
    }

    /**
     * Returns the table associated with this row
     */
    public function getTable(): Table
    {
        return $this->table;
    }

    /**
     * Return the value of all rows
     */
    public function __get(string $name)
    {
        //It's a field
        if (isset($this->table->{$name})) {
            $result = [];

            foreach ($this->rows as $id => $row) {
                $result[$id] = $row->$name;
            }

            return $result;
        }

        //It's a custom data
        if (array_key_exists($name, $this->data)) {
            return $this->data[$name];
        }

        $db = $this->table->getDatabase();

        if (isset($db->$name)) {
            $table = $db->$name;
            $joinTable = $this->table->getJoinTable($table);

            //Many-to-many
            if (!$this->count()) {
                $result = $table->createCollection();

                $this->link($table, $result);
            } elseif ($joinTable) {
                $joinRows = $this->select($joinTable)->run();
                $result = $joinRows->select($table)->run();

                $this->link($table, $result, $joinRows);
            } else {
                $result = $this->select($table)->run();
                $this->link($table, $result);
            }

            return $result;
        }

        throw new RuntimeException(
            sprintf('Undefined property "%s" in the table "%s"', $name, $this->table)
        );
    }

    public function link(Table $table, RowCollection $rows, RowCollection $relations = null)
    {
        if ($relations) {
            return $this->linkThrough($rows, $relations);
        }

        //Has many (inversed of Has one)
        if ($this->table->getJoinField($table)) {
            return $rows->link($this->table, $this);
        }

        $relations = [];
        $foreignKey = $this->table->getForeignKey();

        foreach ($rows as $row) {
            $id = $row->{$foreignKey};
            $row->link($this->table, $this[$id]);

            if (!isset($relations[$id])) {
                $relations[$id] = [];
            }

            $relations[$id][] = $row;
        }

        foreach ($this as $id => $row) {
            $row->link($table, $table->createCollection($relations[$id] ?? []));
        }

        $this->data[$table->getName()] = $rows;
    }

    private function linkThrough(RowCollection $rows, RowCollection $relations)
    {
        $table = $rows->getTable();
        $relTable = $relations->getTable();
        $this_fk = $this->table->getForeignKey();
        $rows_fk = $table->getForeignKey();
        $this_in_rows = [];
        $rows_in_this = [];

        foreach ($relations as $relation) {
            $this_id = $relation->{$this_fk};
            $rows_id = $relation->{$rows_fk};

            if (!isset($rows_in_this[$this_id])) {
                $rows_in_this[$this_id] = [];
            }

            $rows_in_this[$this_id][] = $rows[$rows_id];

            if (!isset($this_in_rows[$rows_id])) {
                $this_in_rows[$rows_id] = [];
            }

            $this_in_rows[$rows_id][] = $this[$this_id];
        }

        foreach ($this as $id => $row) {
            $row->link($table, $table->createCollection($rows_in_this[$id] ?? []));
        }

        foreach ($rows as $id => $row) {
            $row->link($this->table, $this->table->createCollection($this_in_rows[$id] ?? []));
        }

        $this->data[$table->getName()] = $rows;

        $rows->link($relTable, $relations);
        $this->link($relTable, $relations);
    }

    /**
     * Change a property of all rows
     * @param mixed $value
     */
    public function __set(string $name, $value)
    {
        foreach ($this->rows as $row) {
            $row->$name = $value;
        }
    }

    /**
     * Check whether a value is set or not
     * @param mixed $name
     */
    public function __isset($name)
    {
        return isset($this->table->{$name});
    }

    /**
     * @see ArrayAccess
     * @param mixed $offset
     * @param mixed $value
     */
    public function offsetSet($offset, $value)
    {
        throw new RuntimeException('RowCollection are read-only');
    }

    /**
     * @see ArrayAccess
     * @param mixed $offset
     */
    public function offsetExists($offset)
    {
        return isset($this->rows[$offset]);
    }

    /**
     * @see ArrayAccess
     * @param mixed $offset
     */
    public function offsetUnset($offset)
    {
        throw new RuntimeException('RowCollection are read-only');
    }

    /**
     * @see ArrayAccess
     * @param mixed $offset
     */
    public function offsetGet($offset)
    {
        return $this->rows[$offset] ?? null;
    }

    /**
     * @see Iterator
     */
    public function rewind()
    {
        return reset($this->rows);
    }

    /**
     * @see Iterator
     */
    public function current()
    {
        return current($this->rows);
    }

    /**
     * @see Iterator
     */
    public function key()
    {
        return key($this->rows);
    }

    /**
     * @see Iterator
     */
    public function next()
    {
        return next($this->rows);
    }

    /**
     * @see Iterator
     */
    public function valid()
    {
        return key($this->rows) !== null;
    }

    /**
     * @see Countable
     */
    public function count()
    {
        return count($this->rows);
    }

    /**
     * Returns an array with all fields of all rows
     */
    public function toArray(): array
    {
        return array_map(function ($row) {
            return $row->toArray();
        }, $this->rows);
    }

    /**
     * Save all rows in the database
     */
    public function save(): self
    {
        foreach ($this->rows as $row) {
            $row->save();
        }

        return $this;
    }

    /**
     * Delete all rows in the database
     */
    public function delete(): self
    {
        $ids = array_values($this->id);

        if (count($ids)) {
            $this->table->delete()
                ->where('id IN ', $ids)
                ->run();

            $this->id = null;
        }

        return $this;
    }

    /**
     * Creates a select query of a table related with this row collection
     */
    public function select(Table $table): Select
    {
        return $table->select()->relatedWith($this);
    }
}


<?php
declare(strict_types = 1);

namespace SimpleCrud;

use SimpleCrud\Fields\Boolean;
use SimpleCrud\Fields\Date;
use SimpleCrud\Fields\Datetime;
use SimpleCrud\Fields\Decimal;
use SimpleCrud\Fields\Field;
use SimpleCrud\Fields\Integer;
use SimpleCrud\Fields\Json;
use SimpleCrud\Fields\Point;
use SimpleCrud\Fields\Set;

/**
 * Class to create instances of fields.
 */
final class FieldFactory implements FieldFactoryInterface
{
    private $defaultType = Field::class;
    private $fields = [
        Boolean::class => [
            'names' => ['active', '/^(is|has)[A-Z]/'],
            'types' => ['boolean'],
            'config' => [],
        ],
        Date::class => [
            'names' => [],
            'types' => ['date'],
            'config' => [],
        ],
        Datetime::class => [
            'names' => ['pubdate', '/[a-z]At$/'],
            'types' => ['datetime'],
            'config' => [],
        ],
        Decimal::class => [
            'names' => [],
            'types' => ['decimal', 'float', 'real'],
            'config' => [],
        ],
        Field::class => [
            'names' => [],
            'types' => [],
            'config' => [],
        ],
        Integer::class => [
            'names' => ['id', '/_id$/'],
            'types' => ['bigint', 'int', 'mediumint', 'smallint', 'tinyint', 'year'],
            'config' => [],
        ],
        Json::class => [
            'names' => [],
            'types' => ['json'],
            'config' => [],
        ],
        Point::class => [
            'names' => [],
            'types' => ['point'],
            'config' => [],
        ],
        Set::class => [
            'names' => [],
            'types' => ['set'],
            'config' => [],
        ],
    ];

    public function defineField(string $className, array $definition): self
    {
        $definition += ['names' => [], 'types' => [], 'config' => []];

        if (isset($this->fields[$className])) {
            $this->fields[$className] = $definition;
            return $this;
        }

        $this->fields = [$className => $definition] + $this->fields;

        return $this;
    }

    public function getFieldDefinition(string $className): ?array
    {
        return $this->fields[$className] ?? null;
    }

    /**
     * @see FieldFactoryInterface
     *
     * {@inheritdoc}
     */
    public function get(Table $table, array $info): Field
    {
        $className = $this->getClassName($info['name'], $info['type']);

        if (class_exists($className)) {
            $field = new $className($table, $info);
            $config = $this->fields[$className]['config'] ?? [];

            foreach ($config as $name => $value) {
                $field->setConfig($name, $value);
            }

            return $field;
        }

        throw new SimpleCrudException("No field class found for '{$className}'");
    }

    /**
     * Get the field class name.
     */
    private function getClassName(string $name, string $type): ?string
    {
        foreach ($this->fields as $className => $definition) {
            foreach ($definition['names'] as $defName) {
                if ($defName === $name || ($defName[0] === '/' && preg_match($defName, $name))) {
                    return $className;
                }
            }

            if (in_array($type, $definition['types'])) {
                return $className;
            }
        }

        return $this->defaultType;
    }
}


<?php
declare(strict_types = 1);

namespace SimpleCrud;

use Atlas\Pdo\Connection;
use Atlas\Query\Bind;
use Atlas\Query\Delete;
use Atlas\Query\Insert;
use Atlas\Query\Select;
use Atlas\Query\Update;
use Exception;
use InvalidArgumentException;
use PDO;
use PDOStatement;
use SimpleCrud\Scheme\Mysql;
use SimpleCrud\Scheme\SchemeInterface;
use SimpleCrud\Scheme\Sqlite;

final class Database
{
    const CONFIG_LOCALE = 'locale';

    private $connection;
    private $scheme;
    private $tables = [];
    private $tablesClasses = [];
    private $inTransaction = false;
    private $onExecute;
    private $config = [];

    private $fieldFactory;

    public function __construct(PDO $pdo, SchemeInterface $scheme = null)
    {
        $this->connection = new Connection($pdo);
        $this->scheme = $scheme;

        if ($scheme) {
            return;
        }

        $engine = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

        switch ($engine) {
            case 'mysql':
                $this->scheme = new Mysql($pdo);
                break;
            case 'sqlite':
                $this->scheme = new Sqlite($pdo);
                break;
            default:
                throw new RuntimeException(sprintf('Invalid engine type: %s', $engine));
        }
    }

    /**
     * Configure custom classes for some tables
     * [table => classname]
     */
    public function setTablesClasses(array $classes): self
    {
        $this->tablesClasses = $classes;

        return $this;
    }

    /**
     * Returns a config value
     */
    public function getConfig(string $name)
    {
        return $this->config[$name] ?? null;
    }

    /**
     * Set a config value
     * @param mixed $value
     */
    public function setConfig(string $name, $value): self
    {
        $this->config[$name] = $value;

        return $this;
    }

    /**
     * Return the scheme class
     */
    public function getScheme(): SchemeInterface
    {
        return $this->scheme;
    }

    /**
     * Returns the connection instance.
     */
    public function getConnection(): Connection
    {
        return $this->connection;
    }

    public function select(): Select
    {
        return new Select($this->connection, new Bind());
    }

    public function update(): Update
    {
        return new Update($this->connection, new Bind());
    }

    public function delete(): Delete
    {
        return new Delete($this->connection, new Bind());
    }

    public function insert(): Insert
    {
        return new Insert($this->connection, new Bind());
    }

    /**
     * Set the FieldFactory instance used by the tables.
     */
    public function setFieldFactory(FieldFactory $fieldFactory): self
    {
        $this->fieldFactory = $fieldFactory;

        return $this;
    }

    /**
     * Returns the FieldFactory instance used by the tables.
     */
    public function getFieldFactory(): FieldFactory
    {
        if ($this->fieldFactory === null) {
            return $this->fieldFactory = new FieldFactory();
        }

        return $this->fieldFactory;
    }

    /**
     * Magic method to initialize the tables in lazy mode.
     *
     * @throws SimpleCrudException If the table cannot be instantiated
     */
    public function __get(string $name): Table
    {
        if (isset($this->tables[$name])) {
            return $this->tables[$name];
        }

        if (!$this->__isset($name)) {
            throw new InvalidArgumentException(
                sprintf('The table "%s" does not exist', $name)
            );
        }

        $class = $this->tablesClasses[$name] ?? Table::class;

        return $this->tables[$name] = new $class($this, $name);
    }

    /**
     * Magic method to check if a table exists or not.
     */
    public function __isset(string $name): bool
    {
        return in_array($name, $this->getScheme()->getTables());
    }

    /**
     * Execute a query and returns the statement object with the result.
     *
     * @throws Exception
     */
    public function execute(string $query, array $marks = null): PDOStatement
    {
        $statement = $this->connection->prepare($query);
        $statement->execute($marks);

        return $statement;
    }

    /**
     * Execute a callable inside a transaction.
     *
     * @return mixed The callable returned value
     */
    public function executeTransaction(callable $callable)
    {
        try {
            $transaction = $this->beginTransaction();

            $return = $callable($this);

            if ($transaction) {
                $this->commit();
            }
        } catch (Exception $exception) {
            if ($transaction) {
                $this->rollBack();
            }

            throw $exception;
        }

        return $return;
    }

    /**
     * Returns the last insert id.
     */
    public function lastInsertId(): string
    {
        return $this->connection->lastInsertId();
    }

    /**
     * Starts a transaction if it's not started yet.
     */
    public function beginTransaction(): bool
    {
        if (!$this->inTransaction()) {
            $this->connection->beginTransaction();

            return $this->inTransaction = true;
        }

        return false;
    }

    /**
     * Commits the changes of the transaction to the database.
     */
    public function commit()
    {
        if ($this->inTransaction()) {
            $this->connection->commit();
            $this->inTransaction = false;
        }
    }

    /**
     * RollBack a transaction.
     */
    public function rollBack()
    {
        if ($this->inTransaction()) {
            $this->connection->rollBack();
            $this->inTransaction = false;
        }
    }

    /**
     * Check if there is a transaction opened currently in this adapter.
     */
    public function inTransaction()
    {
        return ($this->inTransaction === true) && ($this->connection->inTransaction() === true);
    }
}


<?php
declare(strict_types = 1);

namespace SimpleCrud\Fields;

use Atlas\Query\Insert;
use Atlas\Query\Select;
use Atlas\Query\Update;

final class Point extends Field
{
    public function select(Select $query)
    {
        $name = $this->getName();
        $query->columns("asText({$this}) as `{$name}`");
    }

    public function format($value): ?array
    {
        if ($value === null) {
            return null;
        }

        //POINT(X Y)
        $points = explode(' ', substr((string) $value, 6, -1), 2);

        return [
            floatval($points[0]),
            floatval($points[1]),
        ];
    }

    public function insert(Insert $query, $value)
    {
        if (self::isValid($value)) {
            $value = sprintf('POINT(%s, %s)', $value[0], $value[1]);
        } else {
            $value = null;
        }

        $query->set($this->info['name'], $value);
    }

    public function update(Update $query, $value)
    {
        if (self::isValid($value)) {
            $value = sprintf('POINT(%s, %s)', $value[0], $value[1]);
        } else {
            $value = null;
        }

        $query->set($this->info['name'], $value);
    }

    /**
     * Check whether the value is valid before save in the database
     * @param mixed $data
     */
    private static function isValid($data): bool
    {
        if (!is_array($data)) {
            return false;
        }

        if (!isset($data[0]) || !isset($data[1]) || count($data) > 2) {
            return false;
        }

        return is_numeric($data[0]) && is_numeric($data[1]);
    }
}


<?php
declare(strict_types = 1);

namespace SimpleCrud;

use BadMethodCallException;
use JsonSerializable;
use RuntimeException;
use SimpleCrud\Events\BeforeSaveRow;
use SimpleCrud\Queries\Select;

/**
 * Stores the data of an table row.
 */
class Row implements JsonSerializable
{
    private $table;
    private $values = [];
    private $changes = [];
    private $data = [];

    public function __construct(Table $table, array $values)
    {
        $this->table = $table;

        if (empty($values['id'])) {
            $this->values = $table->getDefaults();
            $this->changes = $table->getDefaults($values);
            unset($this->changes['id']);
        } else {
            $this->values = $table->getDefaults($values);
        }
    }

    public function __debugInfo(): array
    {
        return [
            'table' => (string) $this->table,
            'values' => $this->values,
            'changes' => $this->changes,
            'data' => $this->data,
        ];
    }

    public function __call(string $name, array $arguments): Select
    {
        $db = $this->table->getDatabase();

        //Relations
        if (isset($db->$name)) {
            return $this->select($db->$name);
        }

        throw new BadMethodCallException(
            sprintf('Invalid method call %s', $name)
        );
    }

    public function setData(array $data): self
    {
        $this->data = $data + $this->data;

        return $this;
    }

    /**
     * @param Row|RowCollection|null $row
     */
    public function link(Table $table, $row = null): self
    {
        return $this->setData([$table->getName() => $row]);
    }

    /**
     * @see JsonSerializable
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }

    /**
     * Magic method to stringify the values.
     */
    public function __toString()
    {
        return json_encode($this, JSON_NUMERIC_CHECK);
    }

    /**
     * Returns the table associated with this row
     */
    public function getTable(): Table
    {
        return $this->table;
    }

    /**
     * Returns the value of:
     * - a value field
     * - a related table
     */
    public function &__get(string $name)
    {
        if ($name === 'id') {
            return $this->values['id'];
        }

        //It's a value
        if ($valueName = $this->getValueName($name)) {
            $value = $this->getValue($valueName);
            return $value;
        }

        //It's custom data
        if (array_key_exists($name, $this->data)) {
            return $this->data[$name];
        }

        $db = $this->table->getDatabase();

        if (isset($db->$name)) {
            $this->setData([
                $name => $this->select($db->$name)->run(),
            ]);

            return $this->data[$name];
        }

        throw new RuntimeException(
            sprintf('Undefined property "%s" in the table %s', $name, $this->table)
        );
    }

    /**
     * Change the value of
     * - a field
     * - a localized field
     * @param mixed $value
     */
    public function __set(string $name, $value)
    {
        if ($name === 'id') {
            if (!is_null($this->values['id']) && !is_null($value)) {
                throw new RuntimeException('The field "id" cannot be overrided');
            }

            $this->values['id'] = $value;

            return $value;
        }

        //It's a value
        if ($valueName = $this->getValueName($name)) {
            if ($this->values[$valueName] === $value) {
                unset($this->changes[$valueName]);
            } else {
                $this->changes[$valueName] = $value;
            }

            return $value;
        }

        throw new RuntimeException(
            sprintf('The field %s does not exists', $name)
        );
    }

    /**
     * Check whether a value is set or not
     */
    public function __isset(string $name): bool
    {
        $valueName = $this->getValueName($name);

        return (isset($valueName) && !is_null($this->getValue($valueName))) || isset($this->data[$name]);
    }

    /**
     * Removes the value of a field
     */
    public function __unset(string $name)
    {
        unset($this->data[$name]);

        $this->__set($name, null);
    }

    /**
     * Returns an array with all fields of the row
     */
    public function toArray(): array
    {
        return $this->changes + $this->values;
    }

    /**
     * Edit the values using an array
     */
    public function edit(array $values): self
    {
        foreach ($values as $name => $value) {
            $this->__set($name, $value);
        }

        return $this;
    }

    /**
     * Insert/update the row in the database
     */
    public function save(): self
    {
        if (!empty($this->changes)) {
            $eventDispatcher = $this->table->getEventDispatcher();

            if ($eventDispatcher) {
                $eventDispatcher->dispatch(new BeforeSaveRow($this));
            }

            if (empty($this->id)) {
                $this->id = $this->table->insert($this->changes)->run();
            } else {
                $this->table->update($this->changes)
                    ->where('id = ', $this->id)
                    ->run();
            }

            $this->values = $this->toArray();
            $this->changes = [];
            $this->table->cache($this);
        }

        return $this;
    }

    /**
     * Delete the row in the database
     */
    public function delete(): self
    {
        $id = $this->id;

        if (!empty($id)) {
            $this->table->delete()
                ->where('id = ', $id)
                ->run();

            $this->values['id'] = null;
        }

        return $this;
    }

    /**
     * Relate this row with other rows
     */
    public function relate(Row ...$rows): self
    {
        $table1 = $this->table;

        foreach ($rows as $row) {
            $table2 = $row->getTable();

            //Has one
            if ($field = $table1->getJoinField($table2)) {
                $this->{$field->getName()} = $row->id;
                continue;
            }

            //Has many
            if ($field = $table2->getJoinField($table1)) {
                $row->{$field->getName()} = $this->id;
                $row->save();
                continue;
            }

            //Has many to many
            if ($joinTable = $table1->getJoinTable($table2)) {
                $joinTable->insert([
                    $joinTable->getJoinField($table1)->getName() => $this->id,
                    $joinTable->getJoinField($table2)->getName() => $row->id,
                ])
                ->run();

                continue;
            }

            throw new RuntimeException(
                sprintf('The tables %s and %s are not related', $table1, $table2)
            );
        }

        return $this->save();
    }

    /**
     * Unrelate this row with other rows
     */
    public function unrelate(Row ...$rows): self
    {
        $table1 = $this->table;

        foreach ($rows as $row) {
            $table2 = $row->getTable();

            //Has one
            if ($field = $table1->getJoinField($table2)) {
                $this->{$field->getName()} = null;
                continue;
            }

            //Has many
            if ($field = $table2->getJoinField($table1)) {
                $row->{$field->getName()} = null;
                $row->save();
                continue;
            }

            //Has many to many
            if ($joinTable = $table1->getJoinTable($table2)) {
                $joinTable->delete()
                    ->where("{$joinTable->getJoinField($table1)} = ", $this->id)
                    ->where("{$joinTable->getJoinField($table2)} = ", $row->id)
                    ->run();

                continue;
            }

            throw new RuntimeException(
                sprintf('The tables %s and %s are not related', $table1, $table2)
            );
        }

        return $this->save();
    }

    /**
     * Unrelate this row with all rows of other tables
     */
    public function unrelateAll(Table ...$tables): self
    {
        $table1 = $this->table;

        foreach ($tables as $table2) {
            //Has one
            if ($field = $table1->getJoinField($table2)) {
                $this->{$field->getName()} = null;
                continue;
            }

            //Has many
            if ($field = $table2->getJoinField($table1)) {
                $table2->update([
                    $field->getName() => null,
                ])
                ->relatedWith($table1)
                ->run();
                continue;
            }

            //Has many to many
            if ($joinTable = $table1->getJoinTable($table2)) {
                $joinTable->delete()
                    ->where("{$joinTable->getJoinField($table1)} = ", $this->id)
                    ->where("{$joinTable->getJoinField($table2)} IS NOT NULL")
                    ->run();

                continue;
            }

            throw new RuntimeException(
                sprintf('The tables %s and %s are not related', $table1, $table2)
            );
        }

        return $this->save();
    }

    /**
     * Creates a select query of a table related with this row
     */
    public function select(Table $table): Select
    {
        //Has one
        if ($this->table->getJoinField($table)) {
            return $table->select()->one()->relatedWith($this);
        }

        return $table->select()->relatedWith($this);
    }

    /**
     * Return the real field name
     */
    private function getValueName(string $name): ?string
    {
        if (array_key_exists($name, $this->values)) {
            return $name;
        }

        //It's a localizable field
        $language = $this->table->getDatabase()->getConfig(Database::CONFIG_LOCALE);

        if (!is_null($language)) {
            $name .= "_{$language}";

            if (array_key_exists($name, $this->values)) {
                return $name;
            }
        }

        return null;
    }

    private function getValue(string $name)
    {
        return array_key_exists($name, $this->changes) ? $this->changes[$name] : $this->values[$name];
    }
}


<?php
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: google/api/quota.proto

namespace Google\Api;

use Google\Protobuf\Internal\GPBType;
use Google\Protobuf\Internal\RepeatedField;
use Google\Protobuf\Internal\GPBUtil;

/**
 * Quota configuration helps to achieve fairness and budgeting in service
 * usage.
 * The quota configuration works this way:
 * - The service configuration defines a set of metrics.
 * - For API calls, the quota.metric_rules maps methods to metrics with
 *   corresponding costs.
 * - The quota.limits defines limits on the metrics, which will be used for
 *   quota checks at runtime.
 * An example quota configuration in yaml format:
 *    quota:
 *      limits:
 *      - name: apiWriteQpsPerProject
 *        metric: library.googleapis.com/write_calls
 *        unit: "1/min/{project}"  # rate limit for consumer projects
 *        values:
 *          STANDARD: 10000
 *      # The metric rules bind all methods to the read_calls metric,
 *      # except for the UpdateBook and DeleteBook methods. These two methods
 *      # are mapped to the write_calls metric, with the UpdateBook method
 *      # consuming at twice rate as the DeleteBook method.
 *      metric_rules:
 *      - selector: "*"
 *        metric_costs:
 *          library.googleapis.com/read_calls: 1
 *      - selector: google.example.library.v1.LibraryService.UpdateBook
 *        metric_costs:
 *          library.googleapis.com/write_calls: 2
 *      - selector: google.example.library.v1.LibraryService.DeleteBook
 *        metric_costs:
 *          library.googleapis.com/write_calls: 1
 *  Corresponding Metric definition:
 *      metrics:
 *      - name: library.googleapis.com/read_calls
 *        display_name: Read requests
 *        metric_kind: DELTA
 *        value_type: INT64
 *      - name: library.googleapis.com/write_calls
 *        display_name: Write requests
 *        metric_kind: DELTA
 *        value_type: INT64
 *
 * Generated from protobuf message google.api.Quota
 */
class Quota extends \Google\Protobuf\Internal\Message
{
    /**
     * List of `QuotaLimit` definitions for the service.
     * Used by metric-based quotas only.
     *
     * Generated from protobuf field repeated .google.api.QuotaLimit limits = 3;
     */
    private $limits;
    /**
     * List of `MetricRule` definitions, each one mapping a selected method to one
     * or more metrics.
     * Used by metric-based quotas only.
     *
     * Generated from protobuf field repeated .google.api.MetricRule metric_rules = 4;
     */
    private $metric_rules;

    /**
     * Constructor.
     *
     * @param array $data {
     *     Optional. Data for populating the Message object.
     *
     *     @type \Google\Api\QuotaLimit[]|\Google\Protobuf\Internal\RepeatedField $limits
     *           List of `QuotaLimit` definitions for the service.
     *           Used by metric-based quotas only.
     *     @type \Google\Api\MetricRule[]|\Google\Protobuf\Internal\RepeatedField $metric_rules
     *           List of `MetricRule` definitions, each one mapping a selected method to one
     *           or more metrics.
     *           Used by metric-based quotas only.
     * }
     */
    public function __construct($data = NULL) {
        \GPBMetadata\Google\Api\Quota::initOnce();
        parent::__construct($data);
    }

    /**
     * List of `QuotaLimit` definitions for the service.
     * Used by metric-based quotas only.
     *
     * Generated from protobuf field repeated .google.api.QuotaLimit limits = 3;
     * @return \Google\Protobuf\Internal\RepeatedField
     */
    public function getLimits()
    {
        return $this->limits;
    }

    /**
     * List of `QuotaLimit` definitions for the service.
     * Used by metric-based quotas only.
     *
     * Generated from protobuf field repeated .google.api.QuotaLimit limits = 3;
     * @param \Google\Api\QuotaLimit[]|\Google\Protobuf\Internal\RepeatedField $var
     * @return $this
     */
    public function setLimits($var)
    {
        $arr = GPBUtil::checkRepeatedField($var, \Google\Protobuf\Internal\GPBType::MESSAGE, \Google\Api\QuotaLimit::class);
        $this->limits = $arr;

        return $this;
    }

    /**
     * List of `MetricRule` definitions, each one mapping a selected method to one
     * or more metrics.
     * Used by metric-based quotas only.
     *
     * Generated from protobuf field repeated .google.api.MetricRule metric_rules = 4;
     * @return \Google\Protobuf\Internal\RepeatedField
     */
    public function getMetricRules()
    {
        return $this->metric_rules;
    }

    /**
     * List of `MetricRule` definitions, each one mapping a selected method to one
     * or more metrics.
     * Used by metric-based quotas only.
     *
     * Generated from protobuf field repeated .google.api.MetricRule metric_rules = 4;
     * @param \Google\Api\MetricRule[]|\Google\Protobuf\Internal\RepeatedField $var
     * @return $this
     */
    public function setMetricRules($var)
    {
        $arr = GPBUtil::checkRepeatedField($var, \Google\Protobuf\Internal\GPBType::MESSAGE, \Google\Api\MetricRule::class);
        $this->metric_rules = $arr;

        return $this;
    }

}