Merge pull request #371 from Karaka-Management/develop

Develop
This commit is contained in:
Dennis Eichhorn 2024-02-25 01:39:34 +01:00 committed by GitHub
commit 381bf25f13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
677 changed files with 18176 additions and 6295 deletions

64
.github/ISSUE_TEMPLATE/bugs.yml vendored Normal file
View File

@ -0,0 +1,64 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["bug"]
assignees:
- spl1nes
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of our software are you running?
options:
- Alpha (Default)
default: 0
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Opera
- Microsoft Edge
- Other
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com)
options:
- label: I agree to follow this project's Code of Conduct
required: true

View File

@ -15,7 +15,6 @@ declare(strict_types=1);
namespace phpOMS\Account;
use phpOMS\Localization\Localization;
use phpOMS\Stdlib\Base\Exception\InvalidEnumValue;
use phpOMS\Validation\Network\Email;
/**
@ -164,12 +163,12 @@ class Account implements \JsonSerializable
*/
public function hasPermission(
int $permission,
int $unit = null,
int $app = null,
string $module = null,
int $category = null,
int $element = null,
int $component = null
?int $unit = null,
?int $app = null,
?string $module = null,
?int $category = null,
?int $element = null,
?int $component = null
) : bool
{
foreach ($this->groups as $group) {
@ -317,70 +316,6 @@ class Account implements \JsonSerializable
$this->email = \mb_strtolower($email);
}
/**
* Get status.
*
* @return int Returns the status (AccountStatus)
*
* @since 1.0.0
*/
public function getStatus() : int
{
return $this->status;
}
/**
* Get status.
*
* @param int $status Status
*
* @return void
*
* @throws InvalidEnumValue This exception is thrown if a invalid status is used
*
* @since 1.0.0
*/
public function setStatus(int $status) : void
{
if (!AccountStatus::isValidValue($status)) {
throw new InvalidEnumValue($status);
}
$this->status = $status;
}
/**
* Get type.
*
* @return int Returns the type (AccountType)
*
* @since 1.0.0
*/
public function getType() : int
{
return $this->type;
}
/**
* Get type.
*
* @param int $type Type
*
* @return void
*
* @throws InvalidEnumValue This exception is thrown if an invalid type is used
*
* @since 1.0.0
*/
public function setType(int $type) : void
{
if (!AccountType::isValidValue($type)) {
throw new InvalidEnumValue($type);
}
$this->type = $type;
}
/**
* Get last activity.
*
@ -445,8 +380,8 @@ class Account implements \JsonSerializable
public function toArray() : array
{
return [
'id' => $this->id,
'name' => [
'id' => $this->id,
'name' => [
$this->name1,
$this->name2,
$this->name3,

View File

@ -71,8 +71,8 @@ final class AccountManager implements \Countable
if ($id === 0) {
$account = new Account(Auth::authenticate($this->session));
if (!isset($this->accounts[$account->getId()])) {
$this->accounts[$account->getId()] = $account;
if (!isset($this->accounts[$account->id])) {
$this->accounts[$account->id] = $account;
}
return $account;
@ -92,8 +92,8 @@ final class AccountManager implements \Countable
*/
public function add(Account $account) : bool
{
if (!isset($this->accounts[$account->getId()])) {
$this->accounts[$account->getId()] = $account;
if (!isset($this->accounts[$account->id])) {
$this->accounts[$account->id] = $account;
return true;
}

View File

@ -14,8 +14,6 @@ declare(strict_types=1);
namespace phpOMS\Account;
use phpOMS\Stdlib\Base\Exception\InvalidEnumValue;
/**
* Account group class.
*
@ -88,38 +86,6 @@ class Group implements \JsonSerializable
return $this->id;
}
/**
* Get group status.
*
* @return int Group status
*
* @since 1.0.0
*/
public function getStatus() : int
{
return $this->status;
}
/**
* Set group status.
*
* @param int $status Group status
*
* @return void
*
* @throws InvalidEnumValue This exception is thrown if an invalid status is used
*
* @since 1.0.0
*/
public function setStatus(int $status) : void
{
if (!GroupStatus::isValidValue($status)) {
throw new InvalidEnumValue($status);
}
$this->status = $status;
}
/**
* Get string representation.
*

View File

@ -24,6 +24,18 @@ namespace phpOMS\Account;
*/
final class NullAccount extends Account
{
/**
* Constructor
*
* @param int $id Model id
*
* @since 1.0.0
*/
public function __construct(int $id = 0)
{
$this->id = $id;
}
/**
* {@inheritdoc}
*/

View File

@ -169,13 +169,13 @@ class PermissionAbstract implements \JsonSerializable
* @since 1.0.0
*/
public function __construct(
int $unit = null,
int $app = null,
string $module = null,
string $from = null,
int $category = null,
int $element = null,
int $component = null,
?int $unit = null,
?int $app = null,
?string $module = null,
?string $from = null,
?int $category = null,
?int $element = null,
?int $component = null,
int $permission = PermissionType::NONE
) {
$this->unit = $unit;
@ -308,12 +308,12 @@ class PermissionAbstract implements \JsonSerializable
*/
public function hasPermission(
int $permission,
int $unit = null,
int $app = null,
string $module = null,
int $category = null,
int $element = null,
int $component = null
?int $unit = null,
?int $app = null,
?string $module = null,
?int $category = null,
?int $element = null,
?int $component = null
) : bool
{
return $permission === PermissionType::NONE ||
@ -352,15 +352,15 @@ class PermissionAbstract implements \JsonSerializable
public function jsonSerialize() : mixed
{
return [
'id' => $this->id,
'unit' => $this->unit,
'app' => $this->app,
'module' => $this->module,
'from' => $this->from,
'category' => $this->category,
'element' => $this->element,
'component' => $this->component,
'permission' => $this->getPermission(),
'id' => $this->id,
'unit' => $this->unit,
'app' => $this->app,
'module' => $this->module,
'from' => $this->from,
'category' => $this->category,
'element' => $this->element,
'component' => $this->component,
'permission' => $this->getPermission(),
];
}
}

View File

@ -135,12 +135,12 @@ trait PermissionHandlingTrait
*/
public function hasPermission(
int $permission,
int $unit = null,
int $app = null,
string $module = null,
int $category = null,
int $element = null,
int $component = null
?int $unit = null,
?int $app = null,
?string $module = null,
?int $category = null,
?int $element = null,
?int $component = null
) : bool
{
foreach ($this->permissions as $p) {

View File

@ -22,7 +22,7 @@ namespace phpOMS\Ai\NeuralNetwork;
* @link https://jingga.app
* @since 1.0.0
*/
class Neuron
final class Neuron
{
/**
* Neuron inputs

View File

@ -85,14 +85,34 @@ final class TesseractOcr
*
* @since 1.0.0
*/
public function parseImage(string $image, array $languages = ['eng'], int $psm = 3, int $oem = 3) : string
public function parseImage(string $image, array $languages = ['eng', 'deu'], int $psm = 3, int $oem = 3) : string
{
$temp = \tempnam(\sys_get_temp_dir(), 'oms_ocr_');
if ($temp === false) {
return '';
}
$extension = 'png';
try {
// Tesseract needs higher dpi to work properly (identify + adjust if necessary)
$dpi = (int) \trim(\implode('', SystemUtils::runProc(
'identify',
'-quiet -format "%x" ' . $image
)));
if ($dpi < 300) {
$split = \explode('.', $image);
$extension = \end($split);
SystemUtils::runProc(
'convert',
'-units PixelsPerInch ' . $image . ' -resample 300 ' . $temp . '.' . $extension
);
$image = $temp . '.' . $extension;
}
// Do actual parsing
SystemUtils::runProc(
self::$bin,
$image . ' '
@ -100,12 +120,20 @@ final class TesseractOcr
. ' -c preserve_interword_spaces=1'
. ' --psm ' . $psm
. ' --oem ' . $oem
. ' -l ' . \implode('+', $languages)
. (empty($languages) ? '' : ' -l ' . \implode('+', $languages))
);
} catch (\Throwable $_) {
if (\is_file($temp . '.' . $extension)) {
\unlink($temp . '.' . $extension);
}
return '';
}
if (\is_file($temp . '.' . $extension)) {
\unlink($temp . '.' . $extension);
}
$filepath = \is_file($temp . '.txt')
? $temp . '.txt'
: $temp;
@ -120,11 +148,7 @@ final class TesseractOcr
$parsed = \file_get_contents($filepath);
if ($parsed === false) {
// @codeCoverageIgnoreStart
\unlink($temp);
return '';
// @codeCoverageIgnoreEnd
$parsed = '';
}
\unlink($filepath);

View File

@ -22,9 +22,244 @@ namespace phpOMS\Algorithm\Clustering;
* @link https://jingga.app
* @see ./clustering_overview.png
* @since 1.0.0
*
* @todo Implement
*/
final class AffinityPropagation
final class AffinityPropagation implements ClusteringInterface
{
/**
* Points of the cluster centers
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $clusterCenters = [];
/**
* Cluster points
*
* Points in clusters (helper to avoid looping the cluster array)
*
* @var array
* @since 1.0.0
*/
private array $clusters = [];
private array $similarityMatrix = [];
private array $responsibilityMatrix = [];
private array $availabilityMatrix = [];
/**
* Original points used for clusters
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $points = [];
/**
* Create similarity matrix from points
*
* @param PointInterface[] $points Points to create the similarity matrix for
*
* @return array<int, array<int, int|float>>
*
* @since 1.0.0
*/
private function createSimilarityMatrix(array $points) : array
{
$n = \count($points);
$coordinates = \count($points[0]->coordinates);
$similarityMatrix = \array_fill(0, $n, []);
$temp = [];
for ($i = 0; $i < $n - 1; ++$i) {
for ($j = $i + 1; $j < $n; ++$j) {
$sum = 0.0;
for ($c = 0; $c < $coordinates; ++$c) {
$sum += ($points[$i]->getCoordinate($c) - $points[$j]->getCoordinate($c)) * ($points[$i]->getCoordinate($c) - $points[$j]->getCoordinate($c));
}
$similarityMatrix[$i][$j] = -$sum;
$similarityMatrix[$j][$i] = -$sum;
$temp[] = $similarityMatrix[$i][$j];
}
}
\sort($temp);
$size = $n * ($n - 1) / 2;
$median = $size % 2 === 0
? ($temp[(int) ($size / 2)] + $temp[(int) ($size / 2 - 1)]) / 2
: $temp[(int) ($size / 2)];
for ($i = 0; $i < $n; ++$i) {
$similarityMatrix[$i][$i] = $median;
}
return $similarityMatrix;
}
/**
* Generate clusters for points
*
* @param PointInterface[] $points Points to cluster
* @param int $iterations Iterations for cluster generation
*
* @return void
*
* @since 1.0.0
*/
public function generateClusters(array $points, int $iterations = 100) : void
{
$this->points = $points;
$n = \count($points);
$this->similarityMatrix = $this->createSimilarityMatrix($points);
$this->responsibilityMatrix = clone $this->similarityMatrix;
$this->availabilityMatrix = clone $this->similarityMatrix;
for ($c = 0; $c < $iterations; ++$c) {
for ($i = 0; $i < $n; ++$i) {
for ($k = 0; $k < $n; ++$k) {
$max = \PHP_INT_MIN;
for ($j = 0; $j < $k; ++$j) {
if (($temp = $this->similarityMatrix[$i][$j] + $this->availabilityMatrix[$i][$j]) > $max) {
$max = $temp;
}
}
for ($j = $k + 1; $j < $n; ++$j) {
if (($temp = $this->similarityMatrix[$i][$j] + $this->availabilityMatrix[$i][$j]) > $max) {
$max = $temp;
}
}
$this->responsibilityMatrix[$i][$k] = (1 - 0.9) * ($this->similarityMatrix[$i][$k] - $max) + 0.9 * $this->responsibilityMatrix[$i][$k];
}
}
for ($i = 0; $i < $n; ++$i) {
for ($k = 0; $k < $n; ++$k) {
$sum = 0.0;
if ($i === $k) {
for ($j = 0; $j < $i; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
for (++$j; $j < $n; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
$this->availabilityMatrix[$i][$k] = (1 - 0.9) * $sum + 0.9 * $this->availabilityMatrix[$i][$k];
} else {
$max = \max($i, $k);
$min = \min($i, $k);
for ($j = 0; $j < $min; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
for ($j = $min + 1; $j < $max; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
for ($j = $max + 1; $j < $n; ++$j) {
$sum += \max(0.0, $this->responsibilityMatrix[$j][$k]);
}
$this->availabilityMatrix[$i][$k] = (1 - 0.9) * \min(0.0, $this->responsibilityMatrix[$k][$k] + $sum) + 0.9 * $this->availabilityMatrix[$i][$k];
}
}
}
}
// find center points (exemplar)
for ($i = 0; $i < $n; ++$i) {
$temp = $this->responsibilityMatrix[$i][$i] + $this->availabilityMatrix[$i][$i];
if ($temp > 0) {
$this->clusterCenters[$i] = $this->points[$i];
}
}
}
/**
* Find the nearest group for a point
*
* @param array<int, array<int, int|float> $similarityMatrix Similarity matrix
* @param int $point Point id in the similarity matrix to compare
*
* @return int
*
* @since 1.0.0
*/
private function findNearestGroup(array $similarityMatrix, int $point) : int
{
$maxSim = \PHP_INT_MIN;
$group = 0;
foreach ($this->clusterCenters as $c => $_) {
if ($similarityMatrix[$point][$c] > $maxSim) {
$maxSim = $similarityMatrix[$point][$c];
$group = $c;
}
}
return $group;
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
$points = clone $this->points;
$points[] = $point;
$similarityMatrix = $this->createSimilarityMatrix($points);
$c = $this->findNearestGroup(
$similarityMatrix,
\count($points) - 1,
);
return $this->clusterCenters[$c];
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
if (!empty($this->clusters)) {
return $this->clusters;
}
$n = \count($this->points);
for ($i = 0; $i < $n; ++$i) {
$group = $this->findNearestGroup($this->points, $i);
$this->clusters[$group] = $this->points[$i];
}
return $this->clusters;
}
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
}

View File

@ -14,17 +14,109 @@ declare(strict_types=1);
namespace phpOMS\Algorithm\Clustering;
use phpOMS\Math\Topology\MetricsND;
/**
* Clustering points
*
* The parent category of this clustering algorithm is hierarchical clustering.
*
* @package phpOMS\Algorithm\Clustering
* @license Base: MIT Copyright (c) 2020 Greene Laboratory
* @license OMS License 2.0
* @link https://jingga.app
* @see ./DivisiveClustering.php
* @see ./clustering_overview.png
* @see https://en.wikipedia.org/wiki/Hierarchical_clustering
* @see https://github.com/greenelab/hclust/blob/master/README.md
* @since 1.0.0
*
* @todo Implement
* @todo Implement missing linkage functions
*/
final class AgglomerativeClustering
final class AgglomerativeClustering implements ClusteringInterface
{
/**
* Metric to calculate the distance between two points
*
* @var \Closure
* @since 1.0.0
*/
private \Closure $metric;
/**
* Metric to calculate the distance between two points
*
* @var \Closure
* @since 1.0.0
*/
private \Closure $linkage;
/**
* Constructor
*
* @param null|\Closure $metric metric to use for the distance between two points
*
* @since 1.0.0
*/
public function __construct(?\Closure $metric = null, ?\Closure $linkage = null)
{
$this->metric = $metric ?? function (PointInterface $a, PointInterface $b) {
$aCoordinates = $a->coordinates;
$bCoordinates = $b->coordinates;
return MetricsND::euclidean($aCoordinates, $bCoordinates);
};
$this->linkage = $linkage ?? function (array $a, array $b, array $distances) {
return self::averageDistanceLinkage($a, $b, $distances);
};
}
/**
* Maximum/Complete-Linkage clustering
*/
public static function maximumDistanceLinkage(array $setA, array $setB, array $distances) : float
{
$max = \PHP_INT_MIN;
foreach ($setA as $a) {
foreach ($setB as $b) {
if ($distances[$a][$b] > $max) {
$max = $distances[$a][$b];
}
}
}
return $max;
}
/**
* Minimum/Single-Linkage clustering
*/
public static function minimumDistanceLinkage(array $setA, array $setB, array $distances) : float
{
$min = \PHP_INT_MAX;
foreach ($setA as $a) {
foreach ($setB as $b) {
if ($distances[$a][$b] < $min) {
$min = $distances[$a][$b];
}
}
}
return $min;
}
/**
* Unweighted average linkage clustering (UPGMA)
*/
public static function averageDistanceLinkage(array $setA, array $setB, array $distances) : float
{
$distance = 0;
foreach ($setA as $a) {
$distance += \array_sum($distances[$a]);
}
return $distance / \count($setA) / \count($setB);
}
}

View File

@ -25,6 +25,6 @@ namespace phpOMS\Algorithm\Clustering;
*
* @todo Implement
*/
final class Birch
final class Birch implements ClusteringInterface
{
}

View File

@ -0,0 +1,71 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Algorithm\Clustering
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Algorithm\Clustering;
/**
* Clustering interface.
*
* @package phpOMS\Algorithm\Clustering;
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
interface ClusteringInterface
{
/**
* Get cluster centroids
*
* @return PointInterface[]
*
* @since 1.0.0
*/
public function getCentroids() : array;
/**
* Get cluster assignments of the training data
*
* @return PointInterface[]
*
* @since 1.0.0
*/
public function getClusters() : array;
/**
* Cluster a single point
*
* This point doesn't have to be in the training data.
*
* @param PointInterface $point Point to cluster
*
* @return null|PointInterface
*
* @since 1.0.0
*/
public function cluster(PointInterface $point) : ?PointInterface;
/**
* Get noise data.
*
* Data points from the training data that are not part of a cluster.
*
* @return PointInterface[]
*
* @since 1.0.0
*/
public function getNoise() : array;
// Not possible to interface due to different implementations
// public function generateClusters(...) : void
}

View File

@ -29,7 +29,7 @@ use phpOMS\Math\Topology\MetricsND;
*
* @todo Expand to n dimensions
*/
final class DBSCAN
final class DBSCAN implements ClusteringInterface
{
/**
* Epsilon for float comparison.
@ -63,12 +63,20 @@ final class DBSCAN
*/
private array $points = [];
/**
* Points of the cluster centers
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $clusterCenters = [];
/**
* Clusters
*
* Array of points assigned to a cluster
*
* @var array<int, array>
* @var array<int, PointInterface[]>
* @since 1.0.0
*/
private array $clusters = [];
@ -108,7 +116,7 @@ final class DBSCAN
*
* @since 1.0.0
*/
public function __construct(\Closure $metric = null)
public function __construct(?\Closure $metric = null)
{
$this->metric = $metric ?? function (PointInterface $a, PointInterface $b) {
$aCoordinates = $a->coordinates;
@ -215,15 +223,9 @@ final class DBSCAN
}
/**
* Find the cluster for a point
*
* @param PointInterface $point Point to find the cluster for
*
* @return int Cluster id
*
* @since 1.0.0
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : int
public function cluster(PointInterface $point) : ?PointInterface
{
if ($this->convexHulls === []) {
foreach ($this->clusters as $c => $cluster) {
@ -232,18 +234,18 @@ final class DBSCAN
$points[] = $p->coordinates;
}
// @todo: this is only good for 2D. Fix this for ND.
// @todo this is only good for 2D. Fix this for ND.
$this->convexHulls[$c] = MonotoneChain::createConvexHull($points);
}
}
foreach ($this->convexHulls as $c => $hull) {
if (Polygon::isPointInPolygon($point->coordinates, $hull) <= 0) {
return $c;
return $hull;
}
}
return -1;
return null;
}
/**
@ -282,4 +284,48 @@ final class DBSCAN
}
}
}
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
if (!empty($this->clusterCenters)) {
return $this->clusterCenters;
}
$dim = \count(\reset($this->points)->getCoordinates());
foreach ($this->clusters as $cluster) {
$middle = \array_fill(0, $dim, 0);
foreach ($cluster as $point) {
for ($i = 0; $i < $dim; ++$i) {
$middle[$i] += $point->getCoordinate($i);
}
}
for ($i = 0; $i < $dim; ++$i) {
$middle[$i] /= \count($cluster);
}
$this->clusterCenters = new Point($middle);
}
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return $this->noisePoints;
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return $this->clusters;
}
}

View File

@ -17,14 +17,18 @@ namespace phpOMS\Algorithm\Clustering;
/**
* Clustering points
*
* The parent category of this clustering algorithm is hierarchical clustering.
*
* @package phpOMS\Algorithm\Clustering
* @license OMS License 2.0
* @link https://jingga.app
* @see ./AgglomerativeClustering.php
* @see ./clustering_overview.png
* @see https://en.wikipedia.org/wiki/Hierarchical_clustering
* @since 1.0.0
*
* @todo Implement
*/
final class Ward
final class DivisiveClustering implements ClusteringInterface
{
}

View File

@ -25,7 +25,7 @@ use phpOMS\Math\Topology\MetricsND;
* @see ./clustering_overview.png
* @since 1.0.0
*/
final class Kmeans
final class Kmeans implements ClusteringInterface
{
/**
* Epsilon for float comparison.
@ -49,7 +49,23 @@ final class Kmeans
* @var PointInterface[]
* @since 1.0.0
*/
private $clusterCenters = [];
private array $clusterCenters = [];
/**
* Points of the clusters
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $clusters = [];
/**
* Points
*
* @var PointInterface[]
* @since 1.0.0
*/
private array $points = [];
/**
* Constructor
@ -58,7 +74,7 @@ final class Kmeans
*
* @since 1.0.0
*/
public function __construct(\Closure $metric = null)
public function __construct(?\Closure $metric = null)
{
$this->metric = $metric ?? function (PointInterface $a, PointInterface $b) {
$aCoordinates = $a->coordinates;
@ -66,18 +82,10 @@ final class Kmeans
return MetricsND::euclidean($aCoordinates, $bCoordinates);
};
//$this->generateClusters($points, $clusters);
}
/**
* Find the cluster for a point
*
* @param PointInterface $point Point to find the cluster for
*
* @return null|PointInterface Cluster center point
*
* @since 1.0.0
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
@ -95,17 +103,21 @@ final class Kmeans
}
/**
* Get cluster centroids
*
* @return array
*
* @since 1.0.0
* {@inheritdoc}
*/
public function getCentroids() : array
{
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
/**
* Generate the clusters of the points
*
@ -118,6 +130,7 @@ final class Kmeans
*/
public function generateClusters(array $points, int $clusters) : void
{
$this->points = $points;
$n = \count($points);
$clusterCenters = $this->kpp($points, $clusters);
$coordinates = \count($points[0]->coordinates);
@ -215,7 +228,7 @@ final class Kmeans
foreach ($points as $key => $point) {
$d[$key] = $this->nearestClusterCenter($point, $clusters)[1];
$sum += $d[$key];
$sum += $d[$key];
}
$sum *= \mt_rand(0, \mt_getrandmax()) / \mt_getrandmax();
@ -245,4 +258,21 @@ final class Kmeans
return $clusters;
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
if (!empty($this->clusters)) {
return $this->clusters;
}
foreach ($this->points as $point) {
$c = $this->cluster($point);
$this->clusters[$c] = $point;
}
return $this->clusters;
}
}

View File

@ -25,8 +25,10 @@ use phpOMS\Math\Topology\MetricsND;
* @link https://jingga.app
* @see ./clustering_overview.png
* @since 1.0.0
*
* @todo Implement noise points
*/
final class MeanShift
final class MeanShift implements ClusteringInterface
{
/**
* Min distance for clustering
@ -80,7 +82,7 @@ final class MeanShift
* @var PointInterface[]
* @since 1.0.0
*/
private $clusterCenters = [];
private array $clusterCenters = [];
/**
* Max distance to cluster to be still considered part of cluster
@ -100,7 +102,7 @@ final class MeanShift
*
* @since 1.0.0
*/
public function __construct(\Closure $metric = null, \Closure $kernel = null)
public function __construct(?\Closure $metric = null, ?\Closure $kernel = null)
{
$this->metric = $metric ?? function (PointInterface $a, PointInterface $b) {
$aCoordinates = $a->coordinates;
@ -126,8 +128,9 @@ final class MeanShift
*/
public function generateClusters(array $points, array $bandwidth) : void
{
$shiftPoints = $points;
$maxMinDist = 1;
$this->points = $points;
$shiftPoints = $points;
$maxMinDist = 1;
$stillShifting = \array_fill(0, \count($points), true);
@ -292,13 +295,15 @@ final class MeanShift
}
/**
* Find the cluster for a point
*
* @param PointInterface $point Point to find the cluster for
*
* @return null|PointInterface Cluster center point
*
* @since 1.0.0
* {@inheritdoc}
*/
public function getCentroids() : array
{
return $this->clusterCenters;
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
@ -306,4 +311,20 @@ final class MeanShift
return $this->clusterCenters[$clusterId] ?? null;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return $this->noisePoints;
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return $this->clusters;
}
}

View File

@ -28,7 +28,7 @@ class Point implements PointInterface
* Coordinates of the point
*
* @var array<int, int|float>
* @sicne 1.0.0
* @since 1.0.0
*/
public array $coordinates = [];

View File

@ -25,6 +25,6 @@ namespace phpOMS\Algorithm\Clustering;
*
* @todo Implement
*/
final class SpectralClustering
final class SpectralClustering implements ClusteringInterface
{
}

View File

@ -4,7 +4,7 @@
*
* PHP Version 8.1
*
* @package phpOMS\Algorithm\CoinMatching
* @package phpOMS\Algorithm\Frequency
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
@ -12,14 +12,14 @@
*/
declare(strict_types=1);
namespace phpOMS\Algorithm\CoinMatching;
namespace phpOMS\Algorithm\Frequency;
/**
* Apriori algorithm.
*
* The algorithm cheks how often a set exists in a given set of sets.
* The algorithm checks how often a set exists in a given set of sets.
*
* @package phpOMS\Algorithm\CoinMatching
* @package phpOMS\Algorithm\Frequency
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
@ -39,7 +39,7 @@ final class Apriori
/**
* Generate all possible subsets
*
* @param array $arr Array of eleements
* @param array $arr Array of elements
*
* @return array<array>
*
@ -70,13 +70,14 @@ final class Apriori
*
* The algorithm cheks how often a set exists in a given set of sets.
*
* @param array<array> $sets Sets of a set (e.g. [[1,2,3,4], [1,2], [1]])
* @param array<string[]> $sets Sets of a set (e.g. [[1,2,3,4], [1,2], [1]])
* @param string[] $subset Subset to check for (empty array -> all subsets are checked)
*
* @return array
*
* @since 1.0.0
*/
public static function apriori(array $sets) : array
public static function apriori(array $sets, array $subset = []) : array
{
// Unique single items
$totalSet = [];
@ -90,6 +91,7 @@ final class Apriori
$totalSet = \array_unique($totalSet);
\sort($totalSet);
\sort($subset);
// Combinations of items
$combinations = self::generateSubsets($totalSet);
@ -98,10 +100,18 @@ final class Apriori
$table = [];
foreach ($combinations as &$c) {
\sort($c);
if (!empty($subset) && $c !== $subset) {
continue;
}
$table[\implode(':', $c)] = 0;
}
foreach ($combinations as $combination) {
if (!empty($subset) && $combination !== $subset) {
continue;
}
foreach ($sets as $set) {
foreach ($combination as $item) {
if (!\in_array($item, $set)) {

View File

@ -67,7 +67,7 @@ final class DependencyResolver
$unresolved[] = $dependency;
self::dependencyResolve($dependency, $items, $resolved, $unresolved);
} else {
continue; // circular dependency
return; // circular dependency
}
}

View File

@ -117,7 +117,7 @@ final class MarkovChain
*
* @since 1.0.0
*/
public function generate(int $length, array $start = null) : array
public function generate(int $length, ?array $start = null) : array
{
$orderKeys = \array_keys($this->data);
$orderValues = \array_keys(\reset($this->data));
@ -137,16 +137,14 @@ final class MarkovChain
$cProb += $p;
if ($prob <= $cProb) {
$new = $val;
$new = $val;
break;
}
}
// Couldn't find possible key
if ($new === null) {
$new = $orderValues[\array_rand($orderValues)];
}
$new ??= $orderValues[\array_rand($orderValues)];
$output[] = $new;
$key[] = $new;
@ -177,7 +175,7 @@ final class MarkovChain
$prob = 1.0;
for ($i = $this->order; $i < $length; ++$i) {
$prob *= $this->data[\implode($key)][$path[$i]] ?? 0.0;
$prob *= $this->data[\implode(' ', $key)][$path[$i]] ?? 0.0;
$key[] = $path[$i];
\array_shift($key);

View File

@ -57,7 +57,7 @@ class Job implements JobInterface
public string $name = '';
/**
* Cosntructor.
* Constructor.
*
* @param float $value Value of the job
* @param \DateTime $start Start time of the job

View File

@ -62,7 +62,7 @@ final class Weighted
}
/**
* Search for a none-conflicting job that comes befor a defined job
* Search for a none-conflicting job that comes before a defined job
*
* @param JobInterface[] $jobs List of jobs
* @param int $pivot Job to find the previous job to
@ -130,7 +130,7 @@ final class Weighted
if ($l != -1) {
$value += $valueTable[$l];
$jList = \array_merge($resultTable[$l], $jList);
$jList = \array_merge($resultTable[$l], $jList);
}
if ($value > $valueTable[$i - 1]) {

View File

@ -106,7 +106,7 @@ class Backpack implements BackpackInterface
public function addItem(ItemInterface $item, int | float $quantity = 1) : void
{
$this->items[] = ['item' => $item, 'quantity' => $quantity];
$this->value += $item->getValue() * $quantity;
$this->cost += $item->getCost() * $quantity;
$this->value += $item->getValue() * $quantity;
$this->cost += $item->getCost() * $quantity;
}
}

View File

@ -49,7 +49,7 @@ class Item implements ItemInterface
public string $name = '';
/**
* Cosntructor.
* Constructor.
*
* @param float $value Value of the item
* @param float $cost Cost of the item

View File

@ -80,9 +80,9 @@ class GeneticOptimization
*
* @example See unit test for example use case
*
* @param array<array> $population List of all elements with ther parameters (i.e. list of "objects" as arrays).
* @param array<array> $population List of all elements with their parameters (i.e. list of "objects" as arrays).
* The constraints are defined as array values.
* @param \Closure $fitness Fitness function calculates score/feasability of solution
* @param \Closure $fitness Fitness function calculates score/feasibility of solution
* @param \Closure $mutate Mutation function to change the parameters of an "object"
* @param \Closure $crossover Crossover function to exchange parameter values between "objects".
* Sometimes single parameters can be exchanged but sometimes interdependencies exist between parameters which is why this function is required.

View File

@ -40,7 +40,7 @@ class SimulatedAnnealing
return $x;
}
// can be many things, e.g. swapping parameters, increasing/decrising, random generation
// can be many things, e.g. swapping parameters, increasing/decreasing, random generation
public static function neighbor(array $generation, $parameterCount)
{
$newGeneration = $generation;
@ -57,17 +57,17 @@ class SimulatedAnnealing
*/
// Simulated Annealing algorithm
// @todo allow to create a solution space (currently all soluctions need to be in space)
// @todo: currently only replacing generations, not altering them
// @todo allow to create a solution space (currently all solutions need to be in space)
// @todo currently only replacing generations, not altering them
/**
* Perform optimization
*
* @example See unit test for example use case
*
* @param array $space List of all elements with ther parameters (i.e. list of "objects" as arrays).
* @param array $space List of all elements with their parameters (i.e. list of "objects" as arrays).
* The constraints are defined as array values.
* @param int $initialTemperature Starting temperature
* @param \Closure $costFunction Fitness function calculates score/feasability of solution
* @param \Closure $costFunction Fitness function calculates score/feasibility of solution
* @param \Closure $neighbor Neighbor function to find a new solution/neighbor
* @param float $coolingRate Rate at which cooling takes place
* @param int $iterations Number of iterations

View File

@ -54,7 +54,7 @@ class Path
private array $expandedNodes = [];
/**
* Cosntructor.
* Constructor.
*
* @param Grid $grid Grid this path belongs to
*
@ -152,7 +152,7 @@ class Path
}
/**
* Find nodes in bettween two nodes.
* Find nodes in between two nodes.
*
* The path may only contain the jump points or pivot points.
* In order to get every node it needs to be expanded.
@ -190,7 +190,7 @@ class Path
if ($e2 > -$dy) {
$err -= $dy;
$x0 += $sx;
$x0 += $sx;
}
if ($e2 < $dx) {

View File

@ -74,4 +74,37 @@ final class Elo
'elo' => (int) \max($eloNew, $this->MIN_ELO),
];
}
/**
* Calculate an approximated win probability based on elo points.
*
* @param int $elo1 Elo of the player we want to calculate the win probability for
* @param int $elo2 Opponent elo
* @param bool $canDraw Is a draw possible?
*
* @return float
*
* @since 1.0.0
*/
public function winProbability(int $elo1, int $elo2, bool $canDraw = false) : float
{
return $canDraw
? -1.0 // @todo implement
: 1 / (1 + \pow(10, ($elo2 - $elo1) / 400));
}
/**
* Calculate an approximated draw probability based on elo points.
*
* @param int $elo1 Elo of the player we want to calculate the win probability for
* @param int $elo2 Opponent elo
*
* @return float
*
* @since 1.0.0
*/
public function drawProbability(int $elo1, int $elo2) : float
{
return -1.0; // @todo implement
}
}

View File

@ -119,7 +119,7 @@ final class Glicko2
// Step 0:
$rdOld /= self::Q;
$elo = ($elo - $this->DEFAULT_ELO) / self::Q;
$elo = ($elo - $this->DEFAULT_ELO) / self::Q;
foreach ($oElo as $idx => $value) {
$oElo[$idx] = ($value - $this->DEFAULT_ELO) / self::Q;

View File

@ -25,22 +25,67 @@ use phpOMS\Math\Stochastic\Distribution\NormalDistribution;
* @since 1.0.0
* @see https://www.moserware.com/assets/computing-your-skill/The%20Math%20Behind%20TrueSkill.pdf
*
* @todo implement https://github.com/sublee/trueskill/blob/master/trueskill/__init__.py
* @todo Implement https://github.com/sublee/trueskill/blob/master/trueskill/__init__.py
* https://github.com/Karaka-Management/phpOMS/issues/337
*/
class TrueSkill
{
public int $DEFAULT_MU = 25;
public const DEFAULT_MU = 25;
public float $DEFAULT_SIGMA = 25 / 3;
public const DEFAULT_SIGMA = 25 / 3;
public float $DEFAULT_BETA = 25 / 3 / 2;
public const DEFAULT_BETA = 25 / 3 / 2;
public float $DEFAULT_TAU = 25 / 3 / 100;
public const DEFAULT_TAU = 25 / 3 / 100;
public float $DEFAULT_DRAW_PROBABILITY = 0.1;
public const DEFAULT_DRAW_PROBABILITY = 0.1;
public function __construct()
private float $mu = 0.0;
private float $sigma = 0.0;
private float $beta = 0.0;
private float $tau = 0.0;
private float $drawProbability = 0.0;
public function __construct(
?float $mu = null,
?float $sigma = null,
?float $beta = null,
?float $tau = null,
?float $drawProbability = null)
{
$this->mu = $mu ?? self::DEFAULT_MU;
$this->sigma = $sigma ?? self::DEFAULT_SIGMA;
$this->beta = $beta ?? self::DEFAULT_BETA;
$this->tau = $tau ?? self::DEFAULT_TAU;
$this->drawProbability = $drawProbability ?? self::DEFAULT_DRAW_PROBABILITY;
}
public function winProbability(array $team1, array $team2, float $drawMargin = 0.0)
{
$sigmaSum = 0.0;
$mu1 = 0.0;
foreach ($team1 as $player) {
$mu1 += $player->mu;
$sigmaSum += $player->sigma * $player->sigma;
}
$mu2 = 0.0;
foreach ($team2 as $player) {
$mu2 += $player->mu;
$sigmaSum += $player->sigma * $player->sigma;
}
$deltaMu = $mu1 - $mu2;
return NormalDistribution::getCdf(
($deltaMu - $drawMargin) / \sqrt((\count($team1) + \count($team2)) * ($this->beta * $this->beta) + $sigmaSum),
0,
1
);
}
// Draw margin = epsilon

View File

@ -34,13 +34,16 @@ final class Nominatim
/**
* {@inheritdoc}
*/
public static function geocoding(string $country, string $city, string $address = '') : array
public static function geocoding(string $country, string $city, string $address = '', string $postal = '') : array
{
$URL = 'https://nominatim.openstreetmap.org/search.php?format=jsonv2';
$request = new HttpRequest(
new HttpUri(
$URL . '&country=' . \urlencode($country) . '&city=' . \urlencode($city) . ($address === '' ? '' : '&street=' . \urlencode($address))
$URL . '&country=' . \urlencode($country)
. '&city=' . \urlencode($city)
. ($address === '' ? '' : '&street=' . \urlencode($address))
. ($postal === '' ? '' : '&postalcode=' . \urlencode($postal))
)
);
$request->setMethod(RequestMethod::GET);

View File

@ -0,0 +1,36 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Stdlib\Base\Enum;
/**
* Auth Status
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class AuthStatus extends Enum
{
public const OK = 0;
public const FAILED = -1;
public const BLOCKED = -2;
public const LIMIT_EXCEEDED = -3;
}

34
Api/Shipping/AuthType.php Normal file
View File

@ -0,0 +1,34 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Stdlib\Base\Enum;
/**
* Auth Type
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class AuthType extends Enum
{
public const AUTOMATIC_LOGIN = 2;
public const MANUAL_LOGIN = 4;
public const KEY_LOGIN = 8;
}

View File

@ -0,0 +1,405 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DHL
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DHL;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\AuthType;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\System\MimeType;
use phpOMS\Uri\HttpUri;
/**
* Shipment API.
*
* In the third party API the following definitions are important to know:
*
* 1. Order: A collection of shipments with the same service (standard, return, packet plus, packet tracked)
* 2. Item: A shipment/package
*
* @package phpOMS\Api\Shipping\DHL
* @license OMS License 2.0
* @link https://jingga.app
* @see General: https://developer.dhl.com/
* @see Special: https://developer.dhl.com/api-reference/deutsche-post-international-post-parcel-germany#get-started-section/
* @see Tracking: https://developer.dhl.com/api-reference/shipment-tracking#get-started-section/
* @since 1.0.0
*/
final class DHLInternationalShipping implements ShippingInterface
{
/**
* Api version
*
* @var string
* @since 1.0.0
*/
public const API_VERSION = 'v1';
/**
* API environment.
*
* @var string
* @since 1.0.0
*/
public static string $ENV = 'live';
/**
* API link to live/production version.
*
* @var string
* @since 1.0.0
*/
public const LIVE_URL = 'https://api-eu.dhl.com/dpi';
/**
* API link to test/sandbox version.
*
* @var string
* @since 1.0.0
*/
public const SANDBOX_URL = 'https://api-sandbox.dhl.com/dpi';
/**
* API link to test/sandbox version.
*
* This implementation uses different testing urls for the different endpoints
*
* @var string
* @since 1.0.0
*/
public const SANDBOX2_URL = 'https://api-test.dhl.com';
/**
* The type of authentication that is supported.
*
* @var int
* @since 1.0.0
*/
public const AUTH_TYPE = AuthType::AUTOMATIC_LOGIN | AuthType::KEY_LOGIN;
/**
* Minimum auth expiration time until re-auth.
*
* @var int
* @since 1.0.0
*/
public const TIME_DELTA = 10;
/**
* Client id
*
* @var string
* @since 1.0.0
*/
public string $client = '';
/**
* Login id
*
* @var string
* @since 1.0.0
*/
public string $login = '';
/**
* Password
*
* @var string
* @since 1.0.0
*/
public string $password = '';
/**
* Current auth token
*
* @var string
* @since 1.0.0
*/
public string $token = '';
/**
* Current auth refresh token in case the token expires
*
* @var string
* @since 1.0.0
*/
public string $refreshToken = '';
/**
* Api Key
*
* @var string
* @since 1.0.0
*/
public string $apiKey = '';
/**
* Token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $expire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $refreshExpire;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
}
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
$this->client = $client ?? $this->client;
$this->login = $login;
$this->password = $password;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/' . self::API_VERSION . '/auth/accesstoken';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Content-Type', MimeType::M_JSON);
$request->header->set('Accept', '*/*');
$request->header->set('Authorization', 'Basic ' . \base64_encode($login . ':' . $password));
$this->expire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getDataString('access_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX2_URL;
$uri = $base . '/track/shipments';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('trackingnumber', $shipment);
$httpUri->addQuery('limit', '10');
// @todo implement: express, parcel-de, ecommerce, dgf, parcel-uk, post-de, sameday, freight, parcel-nl, parcel-pl, dsc, ecommerce-europe, svb
//$httpUri->addQuery('service', '');
// @odo: implement
//$httpUri->addQuery('requesterCountryCode', '');
//$httpUri->addQuery('originCountryCode', '');
//$httpUri->addQuery('recipientPostalCode', '');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('accept', MimeType::M_JSON);
$request->header->set('dhl-api-key', $this->apiKey);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$shipments = $response->getDataArray('shipments') ?? [];
$tracking = [];
// @todo add general shipment status (not just for individual packages)
foreach ($shipments as $shipment) {
$packages = [];
$package = $shipment;
$activities = [];
foreach ($package['events'] as $activity) {
$activities[] = [
'date' => new \DateTime($activity['timestamp']),
'description' => $activity['description'],
'location' => [
'address' => [
$activity['location']['address']['streetAddress'],
$activity['location']['address']['addressLocality'],
],
'city' => '',
'country' => '',
'country_code' => $activity['location']['address']['countryCode'],
'zip' => $activity['location']['address']['postalCode'],
'state' => '',
],
'status' => [
'code' => $activity['statusCode'],
'statusCode' => $activity['statusCode'],
'description' => $activity['status'],
],
];
}
$packages[] = [
'status' => [
'code' => $package['status']['statusCode'],
'statusCode' => $package['status']['statusCode'],
'description' => $package['status']['status'],
],
'deliveryDate' => new \DateTime($package['estimatedTimeOfDelivery']),
'count' => $package['details']['totalNumberOfPieces'],
'weight' => $package['details']['weight']['weight'],
'weight_unit' => 'g',
'activities' => $activities,
'received' => [
'by' => $package['details']['proofOfDelivery']['familyName'],
'signature' => $package['details']['proofOfDelivery']['signatureUrl'],
'location' => '',
'date' => $package['details']['proofOfDelivery']['timestamp'],
],
];
$tracking[] = $packages;
}
return $tracking;
}
/**
* Get label information for shipment
*
* @param string $shipment Shipment id or token
*
* @return array
*
* @since 1.0.0
*/
public function label(string $shipment) : array
{
return [];
}
/**
* Finalize shipments (no further changes possible)
*
* @param string[] $shipment Shipments to finalize
*
* @return bool
*
* @since 1.0.0
*/
public function finalize(array $shipment = []) : bool
{
return true;
}
}

View File

@ -0,0 +1,659 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DHL
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DHL;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\AuthType;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Localization\ISO3166CharEnum;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\System\MimeType;
use phpOMS\Uri\HttpUri;
/**
* Shipment API.
*
* @package phpOMS\Api\Shipping\DHL
* @license OMS License 2.0
* @link https://jingga.app
* @see General: https://developer.dhl.com/
* @see Special: https://developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2#get-started-section/
* @see Tracking: https://developer.dhl.com/api-reference/shipment-tracking#get-started-section/
* @since 1.0.0
*/
final class DHLParcelDEShipping implements ShippingInterface
{
/**
* Api version
*
* @var string
* @since 1.0.0
*/
public const API_VERSION = 'v2';
/**
* API environment.
*
* @var string
* @since 1.0.0
*/
public static string $ENV = 'live';
/**
* API link to live/production version.
*
* @var string
* @since 1.0.0
*/
public const LIVE_URL = 'https://api-eu.dhl.com';
/**
* API link to test/sandbox version.
*
* @var string
* @since 1.0.0
*/
public const SANDBOX_URL = 'https://api-sandbox.dhl.com';
/**
* API link to test/sandbox version.
*
* This implementation uses different testing urls for the different endpoints
*
* @var string
* @since 1.0.0
*/
public const SANDBOX2_URL = 'https://api-test.dhl.com';
/**
* The type of authentication that is supported.
*
* @var int
* @since 1.0.0
*/
public const AUTH_TYPE = AuthType::AUTOMATIC_LOGIN | AuthType::KEY_LOGIN;
/**
* Minimum auth expiration time until re-auth.
*
* @var int
* @since 1.0.0
*/
public const TIME_DELTA = 10;
/**
* Client id
*
* @var string
* @since 1.0.0
*/
public string $client = '';
/**
* Login id
*
* @var string
* @since 1.0.0
*/
public string $login = '';
/**
* Password
*
* @var string
* @since 1.0.0
*/
public string $password = '';
/**
* Current auth token
*
* @var string
* @since 1.0.0
*/
public string $token = '';
/**
* Current auth refresh token in case the token expires
*
* @var string
* @since 1.0.0
*/
public string $refreshToken = '';
/**
* Api Key
*
* @var string
* @since 1.0.0
*/
public string $apiKey = '';
/**
* Token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $expire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $refreshExpire;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
}
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
$this->apiKey = $client ?? $this->client;
$this->login = $login;
$this->password = $password;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->header->set('dhl-api-key', $this->apiKey);
$this->expire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 500:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
$this->apiKey = $key;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Accept', MimeType::M_JSON);
$request->header->set('dhl-api-key', $key);
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 500:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/orders';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('validate', 'true');
// @todo implement docFormat
$httpUri->addQuery('docFormat', 'PDF');
// @todo implement printFormat
// Available values : A4, 910-300-600, 910-300-610, 910-300-700, 910-300-700-oz, 910-300-710, 910-300-300, 910-300-300-oz, 910-300-400, 910-300-410, 100x70mm
// If not set, default specified in customer portal will be used
// @todo implement as class setting
//$request->setData('printFormat', '');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', MimeType::M_JSON);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->setData('STANDARD_GRUPPENPROFIL', 'PDF');
$shipments = [
[
'product' => 'V01PAK', // V53WPAK, V53WPAK
'billingNumber' => $data['costcenter'], // @todo maybe dhl number, check
'refNo' => $package['id'],
'shipper' => [
'name1' => $sender['name'],
'addressStreet' => $sender['address'],
'additionalAddressInformation1' => $sender['address_addition'],
'postalCode' => $sender['zip'],
'city' => $sender['city'],
'country' => ISO3166CharEnum::getBy2Code($sender['country_code']),
'email' => $sender['email'],
'phone' => $sender['phone'],
],
'consignee' => [
'name1' => $receiver['name'],
'addressStreet' => $receiver['address'],
'additionalAddressInformation1' => $receiver['address_addition'],
'postalCode' => $receiver['zip'],
'city' => $receiver['city'],
'country' => ISO3166CharEnum::getBy2Code($receiver['country_code']),
'email' => $receiver['email'],
'phone' => $receiver['phone'],
],
'details' => [
'dim' => [
'uom' => 'mm',
'height' => $package['height'],
'length' => $package['length'],
'width' => $package['width'],
],
'weight' => [
'uom' => 'g',
'value' => $package['weight'],
],
],
],
];
$request->setData('shipments', $shipments);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$result = $response->getDataArray('items') ?? [];
$labelUri = new HttpUri($result[0]['label']['url']);
$label = $this->label($labelUri->getQuery('token'));
return [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
],
'packages' => [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
],
],
];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/orders';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::DELETE);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->setData('profile', 'STANDARD_GRUPPENPROFIL');
$request->setData('shipment', $shipment);
$response = Rest::request($request);
return $response->header->status === 200;
}
/**
* Get shipment information (no tracking)
*
* This includes depending on service labels, shipping documents and general shipment information.
* For some services this function simply re-creates the data from ship().
*
* @param string $shipment Shipment id or token
*
* @return array
*
* @since 1.0.0
*/
public function info(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/orders';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('shipment', $shipment);
// @todo implement docFormat etc
$httpUri->addQuery('docFormat', 'PDF');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$result = $response->getDataArray('items') ?? [];
$labelUri = new HttpUri($result[0]['label']['url']);
$label = $this->label($labelUri->getQuery('token'));
return [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
],
'packages' => [
'id' => $result[0]['shipmentNo'],
'label' => [
'code' => $result[0]['label']['format'],
'url' => $result[0]['label']['url'],
'data' => $label['data'],
],
],
];
}
/**
* Get label information for shipment
*
* @param string $shipment Shipment id or token
*
* @return array
*
* @since 1.0.0
*/
public function label(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/labels';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('token', $shipment);
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('Content-Type', MimeType::M_PDF);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
return [
'data' => $response->getData(),
];
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX2_URL;
$uri = $base . '/track/shipments';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('trackingnumber', $shipment);
$httpUri->addQuery('limit', '10');
// @todo implement: express, parcel-de, ecommerce, dgf, parcel-uk, post-de, sameday, freight, parcel-nl, parcel-pl, dsc, ecommerce-europe, svb
//$httpUri->addQuery('service', '');
// @odo: implement
//$httpUri->addQuery('requesterCountryCode', '');
//$httpUri->addQuery('originCountryCode', '');
//$httpUri->addQuery('recipientPostalCode', '');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('accept', MimeType::M_JSON);
$request->header->set('dhl-api-key', $this->apiKey);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$shipments = $response->getDataArray('shipments') ?? [];
$tracking = [];
// @todo add general shipment status (not just for individual packages)
foreach ($shipments as $shipment) {
$packages = [];
$package = $shipment;
$activities = [];
foreach ($package['events'] as $activity) {
$activities[] = [
'date' => new \DateTime($activity['timestamp']),
'description' => $activity['description'],
'location' => [
'address' => [
$activity['location']['address']['streetAddress'],
$activity['location']['address']['addressLocality'],
],
'city' => '',
'country' => '',
'country_code' => $activity['location']['address']['countryCode'],
'zip' => $activity['location']['address']['postalCode'],
'state' => '',
],
'status' => [
'code' => $activity['statusCode'],
'statusCode' => $activity['statusCode'],
'description' => $activity['status'],
],
];
}
$packages[] = [
'status' => [
'code' => $package['status']['statusCode'],
'statusCode' => $package['status']['statusCode'],
'description' => $package['status']['status'],
],
'deliveryDate' => new \DateTime($package['estimatedTimeOfDelivery']),
'count' => $package['details']['totalNumberOfPieces'],
'weight' => $package['details']['weight']['weight'],
'weight_unit' => 'g',
'activities' => $activities,
'received' => [
'by' => $package['details']['proofOfDelivery']['familyName'],
'signature' => $package['details']['proofOfDelivery']['signatureUrl'],
'location' => '',
'date' => $package['details']['proofOfDelivery']['timestamp'],
],
];
$tracking[] = $packages;
}
return $tracking;
}
/**
* Get daily manifest
*
* @param \DateTime $date Date of the manifest
*
* @return array
*
* @since 1.0.0
*/
public function getManifest(?\DateTime $date = null) : array
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/manifest';
$httpUri = new HttpUri($uri);
if ($date !== null) {
$httpUri->addQuery('date', $date->format('Y-m-d'));
}
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::GET);
$request->header->set('Accept-Language', 'en-US');
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
return [
'date' => $response->getDataDateTime('manifestDate'),
'b64' => $response->getDataArray('manifest')['b64'],
'zpl2' => $response->getDataArray('manifest')['zpl2'],
'url' => $response->getDataArray('manifest')['url'],
'format' => $response->getDataArray('manifest')['printFormat'],
];
}
/**
* Finalize shipments.
*
* No further adjustments are possible.
*
* @param array $shipment Shipments to finalize. If empty = all
*
* @return bool
*
* @since 1.0.0
*/
public function finalize(array $shipment = []) : bool
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/parcel/de/shipping/' . self::API_VERSION .'/manifest';
$httpUri = new HttpUri($uri);
$httpUri->addQuery('all', empty($shipment) ? 'true' : 'false');
$request = new HttpRequest($httpUri);
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', MimeType::M_JSON);
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
if (!empty($shipment)) {
$request->setData('shipmentNumbers', $shipment);
}
$response = Rest::request($request);
return $response->header->status === 200;
}
}

View File

@ -0,0 +1,114 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DHL
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DHL;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\DHL
* @license OMS License 2.0
* @link https://jingga.app
* @see General: https://developer.dhl.com/
* @see Special: https://developer.dhl.com/api-reference/ecommerce-europe#get-started-section/
* @see Tracking: https://developer.dhl.com/api-reference/shipment-tracking#get-started-section/
* @since 1.0.0
*/
final class DHLeCommerceShipping implements ShippingInterface
{
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
return [];
}
}

View File

@ -0,0 +1,113 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\DPD
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\DPD;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\DPD
* @license OMS License 2.0
* @link https://jingga.app
* @see https://api.dpd.ro/web-api.html#href-create-shipment-req
* @see https://www.dpd.com/de/en/entwickler-integration-in-ihre-versandloesung/
* @since 1.0.0
*/
final class DPDShipping implements ShippingInterface
{
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
return [];
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\Fedex
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\Fedex;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\Fedex
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.fedex.com/api/en-us/home.html
* @since 1.0.0
*/
final class FedexShipping implements ShippingInterface
{
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
return [];
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\RoyalMail
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\RoyalMail;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\RoyalMail
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.royalmail.net/
* @since 1.0.0
*/
final class RoyalMailShipping implements ShippingInterface
{
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
return [];
}
}

View File

@ -0,0 +1,59 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
/**
* Shipping factory.
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
final class ShippingFactory
{
/**
* Create shipping instance.
*
* @param int $type Shipping type
*
* @return ShippingInterface
*
* @throws \Exception This exception is thrown if the shipping type is not supported
*
* @since 1.0.0
*/
public static function create(int $type) : ShippingInterface
{
switch ($type) {
case ShippingType::DHL:
return new \phpOMS\Api\Shipping\DHL\DHLShipping();
case ShippingType::DPD:
return new \phpOMS\Api\Shipping\DPD\DPDShipping();
case ShippingType::FEDEX:
return new \phpOMS\Api\Shipping\Fedex\FedexShipping();
case ShippingType::ROYALMAIL:
return new \phpOMS\Api\Shipping\RoyalMail\RoyalMailShipping();
case ShippingType::TNT:
return new \phpOMS\Api\Shipping\TNT\TNTShipping();
case ShippingType::UPS:
return new \phpOMS\Api\Shipping\UPS\UPSShipping();
case ShippingType::USPS:
return new \phpOMS\Api\Shipping\Usps\UspsShipping();
default:
throw new \Exception('Unsupported shipping type.');
}
}
}

View File

@ -0,0 +1,171 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipping interface.
*
* For authentication there are usually 3 options depending on the service
* 1. No user interaction: Store login+password in database or code and perform authentication via login+password and receive an access token
* 2. No user interaction: Store api key or secret token in database and perform authentication via key/secret and receive an access token
* 3. User interaction: Redirect to 3rd party login page. User performs manual login. 3rd party page redirects pack to own app after login incl. an access token
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @todo implement Sender, Receiver, Package, Transit, Tracking classes for better type hinting instead of arrays
*
* @property string $ENV ('live' = live environment, 'test' or 'sandbox' = test environment)
* @property string $client
* @property string $token
* @property string $refreshToken
* @property string $apiKey
* @property string $login
* @property string $password
* @property \DateTime $expire
* @property \DateTime $refreshExpire
*/
interface ShippingInterface
{
/**
* Create request for authentication using login and password
*
* @param string $login Login name/email
* @param string $password Password
* @param null|string $client Client id
* @param null|string $payload Other payload data
*
* @return int Returns auth status
*
* @since 1.0.0
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int;
/**
* Create request for manual (user has to use form on external website) authentication.
*
* Creates a request object that redirects to a login page where the user has to enter
* the login credentials. After login the external login page redirects back to the
* redirect url which will also have a parameter containing the authentication token.
*
* Use tokenFromRedirect() to parse the token from the redirect after successful login.
*
* @param string $client Client information (e.g. client id)
* @param null|string $redirect Redirect page after successful login
* @param array $payload Other payload data
*
* @return HttpRequest Request which should be used to create the redirect (e.g. header("Location: $request->uri"))
*
* @see authLogin() for services that require login+password
* @see authApiKey() for services that require api key
*
* @since 1.0.0
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest;
/**
* Parses the redirect code after using authRedirectLogin() and creates a token from that code.
*
* @param string $login Login name/email
* @param string $password Password
* @param HttpRequest $redirect redirect request after the user successfully logged in
*
* @return int Returns auth status
*
* @see authRedirectLogin()
*
* @since 1.0.0
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int;
/**
* Connect to API
*
* @param string $key Api key/permanent token
*
* @return int Returns auth status
*
* @since 1.0.0
*/
public function authApiKey(string $key) : int;
/**
* Refreshes token using a refresh token
*
* @return int Returns auth status
*
* @since 1.0.0
*/
public function refreshToken() : int;
/**
* Create shipment.
*
* @param array $sender Sender
* @param array $shipFrom Ship from location (sometimes sender != pickup location)
* @param array $receiver Receiver
* @param array $package Package
* @param array $data Shipping data
*
* @return array
*
* @since 1.0.0
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array;
/**
* Cancel shipment.
*
* @param string $shipment Shipment id
* @param string[] $packages Packed ids (if a shipment consists of multiple packages)
*
* @return bool
*
* @since 1.0.0
*/
public function cancel(string $shipment, array $packages = []) : bool;
/**
* Track shipment.
*
* @param string $shipment Shipment id
*
* @return array
*
* @since 1.0.0
*/
public function track(string $shipment) : array;
}

View File

@ -0,0 +1,42 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping;
use phpOMS\Stdlib\Base\Enum;
/**
* Shipping Type
*
* @package phpOMS\Api\Shipping
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
abstract class ShippingType extends Enum
{
public const DHL = 1;
public const DPD = 2;
public const FEDEX = 3;
public const ROYALMAIL = 4;
public const TNT = 5;
public const UPS = 6;
public const USPS = 7;
}

View File

@ -0,0 +1,112 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\TNT
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\TNT;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\TNT
* @license OMS License 2.0
* @link https://jingga.app
* @see https://express.tnt.com/expresswebservices-website/app/landing.html
* @since 1.0.0
*/
final class TNTShipping implements ShippingInterface
{
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
return [];
}
}

View File

@ -0,0 +1,787 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\UPS
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\UPS;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\AuthType;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
use phpOMS\Message\Http\RequestMethod;
use phpOMS\Message\Http\Rest;
use phpOMS\System\MimeType;
use phpOMS\Uri\HttpUri;
/**
* Shipment API.
*
* @package phpOMS\Api\Shipping\UPS
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.ups.com/api/reference/oauth/authorization-code?loc=en_US
* @see https://developer.ups.com/api/reference?loc=en_US
* @since 1.0.0
*/
final class UPSShipping implements ShippingInterface
{
/**
* Api version
*
* @var string
* @since 1.0.0
*/
public const API_VERSION = 'v1';
/**
* API environment.
*
* @var string
* @since 1.0.0
*/
public static string $ENV = 'live';
/**
* API link to live/production version.
*
* @var string
* @since 1.0.0
*/
public const LIVE_URL = 'https://onlinetools.ups.com';
/**
* API link to test/sandbox version.
*
* @var string
* @since 1.0.0
*/
public const SANDBOX_URL = 'https://wwwcie.ups.com';
/**
* The type of authentication that is supported.
*
* @var int
* @since 1.0.0
*/
public const AUTH_TYPE = AuthType::AUTOMATIC_LOGIN | AuthType::MANUAL_LOGIN;
/**
* Minimum auth expiration time until re-auth.
*
* @var int
* @since 1.0.0
*/
public const TIME_DELTA = 10;
/**
* Client id
*
* @var string
* @since 1.0.0
*/
public string $client = '';
/**
* Login id
*
* @var string
* @since 1.0.0
*/
public string $login = '';
/**
* Password
*
* @var string
* @since 1.0.0
*/
public string $password = '';
/**
* Current auth token
*
* @var string
* @since 1.0.0
*/
public string $token = '';
/**
* Current auth refresh token in case the token expires
*
* @var string
* @since 1.0.0
*/
public string $refreshToken = '';
/**
* Api Key
*
* @var string
* @since 1.0.0
*/
public string $apiKey = '';
/**
* Token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $expire;
/**
* Refresh token expiration.
*
* @var \DateTime
* @since 1.0.0
*/
public \DateTime $refreshExpire;
/**
* Refresh token expiration.
*
* @since 1.0.0
*/
public function __construct()
{
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
}
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
$this->client = $client ?? $this->client;
$this->login = $login;
$this->password = $password;
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/token';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->setData('grant_type', 'client_credentials');
$request->header->set('Content-Type', MimeType::M_POST);
if ($client !== null) {
$request->header->set('x-merchant-id', $client);
}
$request->header->set('Authorization', 'Basic ' . \base64_encode($login . ':' . $password));
$this->expire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getDataString('access_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/authorize';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->setData('client_id', $client);
$request->setData('redirect_uri', $redirect);
$request->setData('response_type', 'code');
if (isset($payload['id'])) {
$request->setData('scope', $payload['id']);
}
return $request;
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
$code = $redirect->getData('code') ?? '';
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/token';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
// @remark: One api documentation part says redirect_uri is required another says it's not required
// Personally I don't see why a redirect is required or even helpful. Will try without it!
$request->setData('grant_type', 'authorization_code');
$request->setData('code', $code);
$request->header->set('Content-Type', MimeType::M_POST);
$request->header->set('Authorization', 'Basic ' . \base64_encode($login . ':' . $password));
$this->expire = new \DateTime('now');
$this->refreshExpire = new \DateTime('now');
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getDataString('access_token') ?? '';
$this->refreshToken = $response->getDataString('refresh_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$this->refreshExpire->setTimestamp($this->refreshExpire->getTimestamp() + ((int) $response->getData('refresh_token_expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
$now = new \DateTime('now');
if ($this->refreshExpire->getTimestamp() < $now->getTimestamp() - self::TIME_DELTA) {
return AuthStatus::FAILED;
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/security/' . self::API_VERSION . '/oauth/refresh';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', MimeType::M_POST);
$request->header->set('Authorization', 'Basic ' . \base64_encode($this->login . ':' . $this->password));
$request->setData('grant_type', 'refresh_token');
$request->setData('refresh_token', $this->refreshToken);
$this->expire = clone $now;
$this->refreshExpire = clone $now;
$response = Rest::request($request);
switch ($response->header->status) {
case 400:
case 401:
$status = AuthStatus::FAILED;
break;
case 403:
$status = AuthStatus::BLOCKED;
break;
case 429:
$status = AuthStatus::LIMIT_EXCEEDED;
break;
case 200:
$this->token = $response->getDataString('access_token') ?? '';
$this->refreshToken = $response->getDataString('refresh_token') ?? '';
$this->expire->setTimestamp($this->expire->getTimestamp() + ((int) $response->getData('expires_in')));
$this->refreshExpire->setTimestamp($this->refreshExpire->getTimestamp() + ((int) $response->getData('refresh_token_expires_in')));
$status = AuthStatus::OK;
break;
default:
$status = AuthStatus::FAILED;
}
return $status;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function timeInTransit(array $shipFrom, array $receiver, array $package, \DateTime $shipDate) : array
{
if (!$this->validateOrReconnectAuth()) {
return [];
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/shipments/' . self::API_VERSION . '/transittimes';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->header->set('Content-Type', MimeType::M_JSON);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
$request->setData('originCountryCode', $shipFrom['country_code']);
$request->setData('originStateProvince', \substr($shipFrom['state'], 0, 50));
$request->setData('originCityName', \substr($shipFrom['city'], 0, 50));
$request->setData('originPostalCode', \substr($shipFrom['zip'], 0, 10));
$request->setData('destinationCountryCode', $receiver['country_code']);
$request->setData('destinationStateProvince', \substr($receiver['state'], 0, 50));
$request->setData('destinationCityName', \substr($receiver['city'], 0, 50));
$request->setData('destinationPostalCode', \substr($receiver['zip'], 0, 10));
$request->setData('avvFlag', true);
$request->setData('billType', $package['type']);
$request->setData('weight', $package['weight']);
$request->setData('weightUnitOfMeasure', $package['weight_unit']); // LBS or KGS
$request->setData('shipmentContentsValue', $package['value']);
$request->setData('shipmentContentsCurrencyCode', $package['currency']); // 3 char ISO code
$request->setData('numberOfPackages', $package['count']);
$request->setData('shipDate', $shipDate->format('Y-m-d'));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$services = $response->getDataArray('services') ?? [];
$transits = [];
foreach ($services as $service) {
$transits[] = [
'serviceLevel' => $service['serviceLevel'],
'deliveryDate' => new \DateTime($service['deliveryDaye']),
'deliveryDateFrom' => null,
'deliveryDateTo' => null,
];
}
return $transits;
}
/**
* {@inheritdoc}
*/
public function ship(array $sender, array $shipFrom, array $receiver, array $package, array $data) : array
{
if (!$this->validateOrReconnectAuth()) {
return [];
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/shipments/' . self::API_VERSION . '/ship';
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::POST);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
// @todo dangerous goods
// @todo implement printing standard (pdf-zpl/format and size)
$body = [
'Request' => [
'RequestOption' => 'validate',
'SubVersion' => '2205',
],
'Shipment' => [
'Description' => $package['description'],
'DocumentsOnlyIndicator' => '0',
'Shipper' => [
'Name' => \substr($sender['name'], 0, 35),
'AttentionName' => \substr($sender['fao'], 0, 35),
'CompanyDisplayableName' => \substr($sender['name'], 0, 35),
'TaxIdentificationNumber' => \substr($sender['taxid'], 0, 15),
'Phone' => [
'Number' => \substr($sender['phone'], 0, 15),
],
'ShipperNumber' => $sender['number'],
'EMailAddress' => \substr($sender['email'], 0, 50),
'Address' => [
'AddressLine' => \substr($sender['address'], 0, 35),
'City' => \substr($sender['city'], 0, 30),
'StateProvinceCode' => \substr($sender['state'], 0, 5),
'PostalCode' => \substr($sender['zip'], 0, 9),
'CountryCode' => $sender['country_code'],
],
],
'ShipTo' => [
'Name' => \substr($receiver['name'], 0, 35),
'AttentionName' => \substr($receiver['fao'], 0, 35),
'CompanyDisplayableName' => \substr($receiver['name'], 0, 35),
'TaxIdentificationNumber' => \substr($receiver['taxid'], 0, 15),
'Phone' => [
'Number' => \substr($receiver['phone'], 0, 15),
],
'ShipperNumber' => $receiver['number'],
'EMailAddress' => \substr($receiver['email'], 0, 50),
'Address' => [
'AddressLine' => \substr($receiver['address'], 0, 35),
'City' => \substr($receiver['city'], 0, 30),
'StateProvinceCode' => \substr($receiver['state'], 0, 5),
'PostalCode' => \substr($receiver['zip'], 0, 9),
'CountryCode' => $receiver['country_code'],
],
],
/* @todo only allowed for US -> US and PR -> PR shipments?
'ReferenceNumber' => [
'BarCodeIndicator' => '1',
'Code' => '',
'Value' => '',
],
*/
'Service' => [
'Code' => $data['service_code'],
'Description' => \substr($data['service_description'], 0, 35),
],
'InvoiceLineTotal' => [
'CurrencyCode' => $package['currency'],
'MonetaryValue' => $package['value'],
],
'NumOfPiecesInShipment' => $package['count'],
'CostCenter' => \substr($package['costcenter'], 0, 30),
'PackageID' => \substr($package['id'], 0, 30),
'PackageIDBarcodeIndicator' => '1',
'Package' => [],
],
'LabelSpecification' => [
'LabelImageFormat' => [
'Code' => $data['label_code'],
'Description' => \substr($data['label_description'], 0, 35),
],
'LabelStockSize' => [
'Height' => $data['label_height'],
'Width' => $data['label_width'],
],
],
'ReceiptSpecification' => [
'ImageFormat' => [
'Code' => $data['receipt_code'],
'Description' => \substr($data['receipt_description'], 0, 35),
],
],
];
$packages = [];
foreach ($package['packages'] as $p) {
$packages[] = [
'Description' => \substr($p['description'], 0, 35),
'Packaging' => [
'Code' => $p['package_code'],
'Description' => $p['package_description'],
],
'Dimensions' => [
'UnitOfMeasurement' => [
'Code' => $p['package_dim_unit'], // IN or CM or 00 or 01
'Description' => \substr($p['package_dim_unit_description'], 0, 35),
],
'Length' => $p['length'],
'Width' => $p['width'],
'Height' => $p['height'],
],
'DimWeight' => [
'UnitOfMeasurement' => [
'Code' => $p['package_dimweight_unit'],
'Description' => \substr($p['package_dimweight_unit_description'], 0, 35),
],
'Weight' => $p['weight'],
],
'PackageWeight' => [
'UnitOfMeasurement' => [
'Code' => $p['package_weight_unit'],
'Description' => \substr($p['package_weight_unit_description'], 0, 35),
],
'Weight' => $p['weight'],
],
];
}
$body['Shipment']['Package'] = $packages;
// Only required if shipper != shipFrom (e.g. pickup location != shipper)
if (!empty($shipFrom)) {
$body['Shipment']['ShipFrom'] = [
'Name' => \substr($shipFrom['name'], 0, 35),
'AttentionName' => \substr($shipFrom['fao'], 0, 35),
'CompanyDisplayableName' => \substr($shipFrom['name'], 0, 35),
'TaxIdentificationNumber' => \substr($shipFrom['taxid'], 0, 15),
'Phone' => [
'Number' => \substr($shipFrom['phone'], 0, 15),
],
'ShipperNumber' => $shipFrom['number'],
'EMailAddress' => \substr($shipFrom['email'], 0, 50),
'Address' => [
'AddressLine' => \substr($shipFrom['address'], 0, 35),
'City' => \substr($shipFrom['city'], 0, 30),
'StateProvinceCode' => \substr($shipFrom['state'], 0, 5),
'PostalCode' => \substr($shipFrom['zip'], 0, 9),
'CountryCode' => $shipFrom['country_code'],
],
];
}
$request->setData('ShipmentRequest', $body);
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$result = $response->getDataArray('ShipmentResponse') ?? [];
$shipment = [
'id' => $result['ShipmentResults']['ShipmentIdentificationNumber'] ?? '',
'costs' => [
'service' => $result['ShipmentResults']['ShipmentCharges']['BaseServiceCharge']['MonetaryValue'] ?? null,
'transportation' => $result['ShipmentResults']['ShipmentCharges']['TransportationCharges']['MonetaryValue'] ?? null,
'options' => $result['ShipmentResults']['ShipmentCharges']['ServiceOptionsCharges']['MonetaryValue'] ?? null,
'subtotal' => $result['ShipmentResults']['ShipmentCharges']['TotalCharges']['MonetaryValue'] ?? null,
'taxes' => $result['ShipmentResults']['ShipmentCharges']['TaxCharges']['MonetaryValue'] ?? null,
'taxes_type' => $result['ShipmentResults']['ShipmentCharges']['TaxCharges']['Type'] ?? null,
'total' => $result['ShipmentResults']['ShipmentCharges']['TotalChargesWithTaxes']['MonetaryValue'] ?? null,
'currency' => $result['ShipmentResults']['ShipmentCharges']['TotalCharges']['CurrencyCode'] ?? null,
],
'packages' => [],
'label' => [
'code' => '',
'url' => $result['ShipmentResults']['LabelURL'] ?? '',
'barcode' => $result['ShipmentResults']['BarCodeImage'] ?? '',
'local' => $result['ShipmentResults']['LocalLanguageLabelURL'] ?? '',
'data' => '',
],
'receipt' => [
'code' => '',
'url' => $result['ShipmentResults']['ReceiptURL'] ?? '',
'local' => $result['ShipmentResults']['LocalLanguageReceiptURL'] ?? '',
'data' => '',
],
// @todo dangerous goods paper image
];
$packages = [];
foreach ($result['ShipmentResults']['Packages'] as $package) {
$packages[] = [
'id' => $package['TrackingNumber'],
'label' => [
'code' => $package['ShippingLabel']['ImageFormat']['Code'],
'url' => '',
'barcode' => $package['PDF417'],
'image' => $package['ShippingLabel']['GraphicImage'],
'browser' => $package['HTMLImage'],
'data' => '',
],
'receipt' => [
'code' => $package['ShippingReceipt']['ImageFormat']['Code'],
'image' => $package['ShippingReceipt']['ImageFormat']['GraphicImage'],
],
];
}
$shipment['packages'] = $packages;
return $shipment;
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
if (!$this->validateOrReconnectAuth()) {
return false;
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/shipments/' . self::API_VERSION . '/void/cancel/' . $shipment;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::DELETE);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
$request->setData('trackingnumber', empty($shipment) ? $shipment : \implode(',', $packages));
$response = Rest::request($request);
if ($response->header->status !== 200) {
return false;
}
return ($response->getDataArray('VoidShipmentResponse')['Response']['ResponseStatus']['Code'] ?? '0') === '1';
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
if (!$this->validateOrReconnectAuth()) {
return [];
}
$base = self::$ENV === 'live' ? self::LIVE_URL : self::SANDBOX_URL;
$uri = $base . '/api/track/v1/details/' . $shipment;
$request = new HttpRequest(new HttpUri($uri));
$request->setMethod(RequestMethod::GET);
$request->header->set('Authorization', 'Bearer ' . $this->token);
$request->header->set('transId', ((string) \microtime(true)) . '-' . \bin2hex(\random_bytes(6)));
$request->header->set('transactionSrc', 'jingga');
$request->setData('locale', 'en_US');
$request->setData('returnSignature', 'false');
$response = Rest::request($request);
if ($response->header->status !== 200) {
return [];
}
$shipments = $response->getDataArray('trackResponse') ?? [];
$shipments = $shipments['shipment'] ?? [];
$tracking = [];
// @todo add general shipment status (not just for individual packages)
foreach ($shipments as $shipment) {
$packages = [];
foreach ($shipment['package'] as $package) {
$activities = [];
foreach ($package['activity'] as $activity) {
$activities[] = [
'date' => new \DateTime($activity['date'] . ' ' . $activity['time']),
'description' => '',
'location' => [
'address' => [
$activity['location']['address']['addressLine1'],
$activity['location']['address']['addressLine2'],
$activity['location']['address']['addressLine3'],
],
'city' => $activity['location']['address']['city'],
'country' => $activity['location']['address']['country'],
'country_code' => $activity['location']['address']['country_code'],
'zip' => $activity['location']['address']['postalCode'],
'state' => $activity['location']['address']['stateProvice'],
],
'status' => [
'code' => $activity['status']['code'],
'statusCode' => $activity['status']['statusCode'],
'description' => $activity['status']['description'],
],
];
}
$packages[] = [
'status' => [
'code' => $package['status']['code'],
'statusCode' => $package['status']['statusCode'],
'description' => $package['status']['description'],
],
'deliveryDate' => new \DateTime($package['deliveryDate'] . ' ' . $package['deliveryTime']['endTime']),
'count' => $package['packageCount'],
'weight' => $package['weight']['weight'],
'weight_unit' => $package['weight']['unitOfMeasurement'],
'activities' => $activities,
'received' => [
'by' => $package['deliveryInformation']['receivedBy'],
'signature' => $package['deliveryInformation']['signature'],
'location' => $package['deliveryInformation']['location'],
'date' => '',
],
];
}
$tracking[] = $packages;
}
return $tracking;
}
/**
* Validates the current authentication and tries to reconnect if the connection timed out
*
* @return bool
*
* @since 1.0.0
*/
private function validateOrReconnectAuth() : bool
{
$status = AuthStatus::OK;
$now = new \DateTime('now');
if ($this->expire->getTimestamp() < $now->getTimestamp() - self::TIME_DELTA) {
$status = AuthStatus::FAILED;
if ($this->refreshToken !== '') {
$status = $this->refreshToken();
} elseif ($this->login !== '' && $this->password !== '') {
$status = $this->authLogin($this->login, $this->password, $this->client);
}
}
return $status === AuthStatus::OK
&& $this->expire->getTimestamp() > $now->getTimestamp() - self::TIME_DELTA;
}
}

View File

@ -0,0 +1,112 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Api\Shipping\Usps
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Api\Shipping\Usps;
use phpOMS\Api\Shipping\AuthStatus;
use phpOMS\Api\Shipping\ShippingInterface;
use phpOMS\Message\Http\HttpRequest;
/**
* Shipment api.
*
* @package phpOMS\Api\Shipping\Usps
* @license OMS License 2.0
* @link https://jingga.app
* @see https://developer.usps.com/apis
* @since 1.0.0
*/
final class UspsShipping implements ShippingInterface
{
/**
* {@inheritdoc}
*/
public function authLogin(
string $login, string $password,
?string $client = null,
?string $payload = null
) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function authRedirectLogin(
string $client,
?string $redirect = null,
array $payload = []
) : HttpRequest
{
return new HttpRequest();
}
/**
* {@inheritdoc}
*/
public function tokenFromRedirect(
string $login, string $password,
HttpRequest $redirect
) : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function authApiKey(string $key) : int
{
return AuthStatus::FAILED;
}
/**
* {@inheritdoc}
*/
public function refreshToken() : int
{
return 0;
}
/**
* {@inheritdoc}
*/
public function ship(
array $sender,
array $shipFrom,
array $receiver,
array $package,
array $data
) : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cancel(string $shipment, array $packages = []) : bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function track(string $shipment) : array
{
return [];
}
}

View File

@ -63,7 +63,7 @@ abstract class StatusAbstract
*
* @since 1.0.0
*/
public static function activateRoutes(ApplicationInfo $appInfo = null) : void
public static function activateRoutes(?ApplicationInfo $appInfo = null) : void
{
self::installRoutesHooks(static::PATH . '/../Routes.php', static::PATH . '/../Admin/Install/Application/Routes.php');
}
@ -77,7 +77,7 @@ abstract class StatusAbstract
*
* @since 1.0.0
*/
public static function activateHooks(ApplicationInfo $appInfo = null) : void
public static function activateHooks(?ApplicationInfo $appInfo = null) : void
{
self::installRoutesHooks(static::PATH . '/../Hooks.php', static::PATH . '/../Admin/Install/Application/Hooks.php');
}

View File

@ -28,7 +28,7 @@ use phpOMS\Stdlib\Base\Enum;
*/
abstract class LoginReturnType extends Enum
{
public const OK = 0; /* Everything is ok and the user got authed */
public const OK = 0; /* Everything is ok and the user got authenticated */
public const FAILURE = -1; /* Authentication resulted in a unexpected failure */

View File

@ -35,7 +35,7 @@ final class AutoloadException extends \RuntimeException
*
* @since 1.0.0
*/
public function __construct(string $message, int $code = 0, \Exception $previous = null)
public function __construct(string $message, int $code = 0, ?\Exception $previous = null)
{
parent::__construct('File "' . $message . '" could not get loaded.', $code, $previous);
}

View File

@ -138,6 +138,17 @@ final class Autoloader
$class = \ltrim($class, '\\');
$class = \strtr($class, '_\\', '//');
if (self::$useClassMap) {
$nspacePos = \strpos($class, '/');
$subclass = $nspacePos === false ? '' : \substr($class, 0, $nspacePos);
if (isset(self::$classmap[$subclass])) {
$found[] = self::$classmap[$subclass] . $class . '.php';
return $found;
}
}
foreach (self::$paths as $path) {
if (\is_file($file = $path . $class . '.php')) {
$found[] = $file;
@ -172,18 +183,6 @@ final class Autoloader
return;
}
/*
if (!isset($valid[$subclass])) {
foreach (self::$classmap as $map => $path) {
if (\str_starts_with($class, $map)) {
include_once $path . $class . '.php';
return;
}
}
}
*/
}
foreach (self::$paths as $path) {

View File

@ -278,7 +278,7 @@ final class FinanceFormulas
*
* @since 1.0.0
*/
public static function getAnnutiyPaymentFactorPV(float $r, int $n) : float
public static function getAnnuityPaymentFactorPV(float $r, int $n) : float
{
return $r / (1 - \pow(1 + $r, -$n));
}
@ -561,7 +561,7 @@ final class FinanceFormulas
*
* @since 1.0.0
*/
public static function getPrincipalOfCompundInterest(float $C, float $r, int $n) : float
public static function getPrincipalOfCompoundInterest(float $C, float $r, int $n) : float
{
return $C / (\pow(1 + $r, $n) - 1);
}
@ -577,7 +577,7 @@ final class FinanceFormulas
*
* @since 1.0.0
*/
public static function getPeriodsOfCompundInterest(float $P, float $C, float $r) : float
public static function getPeriodsOfCompoundInterest(float $P, float $C, float $r) : float
{
return \log($C / $P + 1) / \log(1 + $r);
}

View File

@ -137,7 +137,7 @@ final class Metrics
}
/**
* Calculate the profitability of customers based on their purchase behaviour
* Calculate the profitability of customers based on their purchase behavior
*
* The basis for the calculation is the migration model using a markov chain
*

View File

@ -127,7 +127,7 @@ final class NetPromoterScore
/**
* Count promoters
*
* Promotoers are all ratings larger 8
* Promoters are all ratings larger 8
*
* @return int Returns the amount of promoters (>= 0)
*

View File

@ -106,7 +106,7 @@ final class PageRank
*
* @since 1.0.0
*/
public function calculateRanks(int $iterations = 20, array $startRank = null) : array
public function calculateRanks(int $iterations = 20, ?array $startRank = null) : array
{
if ($startRank !== null) {
$this->pageRanks = $startRank;

View File

@ -37,7 +37,7 @@ final class ArticleCorrelationAffinity
private array $affinity = [];
/**
* Item order behaviour (when are which items ordered)
* Item order behavior (when are which items ordered)
*
* In tearms of the pearson correlation these are our random variables
*

View File

@ -39,7 +39,7 @@ final class BayesianPersonalizedRanking
// num_factors determines the dimensionality of the latent factor space.
// learning_rate controls the step size for updating the latent factors during optimization.
// regularization prevents overfitting by adding a penalty for large parameter values.
// regularization prevents over-fitting by adding a penalty for large parameter values.
public function __construct(int $numFactors, float $learningRate, float $regularization)
{
$this->numFactors = $numFactors;

View File

@ -87,7 +87,7 @@ final class MemoryCF
*
* @since 1.0.0
*/
public function euclideanDistance(array $ranking, array $rankings) : array
private function euclideanDistance(array $ranking, array $rankings) : array
{
$distances = [];
foreach ($rankings as $idx => $r) {
@ -107,7 +107,7 @@ final class MemoryCF
*
* @since 1.0.0
*/
public function cosineDistance(array $ranking, array $rankings) : array
private function cosineDistance(array $ranking, array $rankings) : array
{
$distances = [];
foreach ($rankings as $idx => $r) {
@ -189,11 +189,9 @@ final class MemoryCF
$anglePointer = \array_keys($cosine);
// Inspect items of the top n comparable users
for ($i = 1; $i <= $size; ++$i) {
$index = (int) ($i / 2) - 1;
$uId = $i % 2 === 1 ? $distancePointer[$index] : $anglePointer[$index];
$distances = $i % 2 === 1 ? $euclidean : $cosine;
for ($i = 0; $i < $size; ++$i) {
$uId = $i % 2 === 0 ? $distancePointer[$i] : $anglePointer[$i];
$distances = $i % 2 === 0 ? $euclidean : $cosine;
foreach ($this->rankings[$uId] as $iId => $_) {
// Item is not already in dataset and not in historic dataset (we are only interested in new)

View File

@ -49,15 +49,30 @@ final class ModelCF
* the multiplication gives a score of how much the user may like that movie.
* A segnificant amount of attributes are required to calculate a good match
*
* @param Matrix $users A mxa matrix where each "m" defines how much the user likes a certain attribute type and "a" defines different users
* @param Matrix $items A bxm matrix where each "b" defines a item and "m" defines how much it belongs to a certain attribute type
* @param array<int|string, array<int|float>> $users A mxa matrix where each "m" defines how much the user likes a certain attribute type and "a" defines different users
* @param array<int|string, array<int|float>> $items A bxm matrix where each "b" defines a item and "m" defines how much it belongs to a certain attribute type
*
* @return array
*
* @since 1.0.0
*/
public static function score(Matrix $users, Matrix $items) : array
public static function score(array $users, array $items) : array
{
return $users->mult($items)->getMatrix();
$matrix = [];
foreach ($users as $uid => $userrow) {
foreach ($items as $iid => $itemrow) {
$matrix[$uid][$iid] = 0.0;
$userrow = \array_values($userrow);
$itemrow = \array_values($itemrow);
foreach ($userrow as $idx => $user) {
$matrix[$uid][$iid] += $user * $itemrow[$idx];
}
}
}
return $matrix;
}
}

View File

@ -18,7 +18,7 @@ namespace phpOMS\Business\Sales;
* Market share calculations (Zipf function)
*
* This class can be used to calculate the market share based on a rank or vice versa
* the rank based on a marketshare in a Zipf distributed market.
* the rank based on a market share in a Zipf distributed market.
*
* @package phpOMS\Business\Sales
* @license OMS License 2.0

View File

@ -0,0 +1,44 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Business\Sales
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Business\Sales;
/**
* Order suggestion calculations
*
* @package phpOMS\Business\Sales
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
final class OrderSuggestion
{
/**
* Constructor
*
* @since 1.0.0
* @codeCoverageIgnore
*/
private function __construct()
{
}
/**
* Calculate the optimal order quantity using the Andler formula
*/
public static function andler(float $annualQuantity, float $orderCosts, float $unitPrice, float $warehousingCostRatio) : float
{
return \sqrt(2 * $annualQuantity * $orderCosts / ($unitPrice * $warehousingCostRatio));
}
}

View File

@ -1,12 +1,16 @@
# Development
## Development environment
The setup and configuration of the development environment is in the hands of every developer themselves. However, it is recommended to follow the setup instructions in the [Developer-Guide](https://github.com/Karaka-Management/Developer-Guide/blob/develop/general/setup.md).
The setup and configuration of the development environment is in the hands of every developer themselves. However, it is recommended to follow the setup instructions in the [Developer-Guide](https://github.com/Karaka-Management/Developer-Guide/blob/develop/general/setup.md).
## Code of conduct
Every organization member and contributor to the organization must follow the [code of conduct](../Policies & Guidelines/Code of conduct.md).
Every organization member and contributor to the organization must follow the [Code of Conduct](../Policies%20&%20Guidelines/Code%20of%20Conduct.md).
## Becoming a contributor
For public repositories you can immediately start by creating forks and pull requests. For private repositories which are necessary to setup the complete developer environment, feel free to request access. Please not that we may not immediately give you access to private repositories and instead will give you smaller tasks regarding public repositories. Please contact info@jingga.app for more details. (**R1**)
For all contributions our [Contributor License Agreement "CLA"](https://github.com/Karaka-Management/Organization-Guide/blob/master/Processes/HR/Hiring/Individual%20Contributor%20License%20Agreement.md) comes into effect. (**R2**)
## Code changes
@ -14,58 +18,106 @@ Every organization member and contributor to the organization must follow the [c
Generally, the development philosophy is result orientated. This means that anyone can propose tasks, pick up existing tasks or right away implement their code changes. However, implementing code changes without consulting with a senior developer in advance has a much higher risk of code changes not getting admitted. The easiest way to discuss a code change idea in advance are the github [issues](https://github.com/Karaka-Management/Karaka/issues) or [discussions](https://github.com/Karaka-Management/Karaka/discussions).
Developers are encouraged to pick open tasks with high priorities according to their own skill level. Senior developers may directly assign tasks to developers based on their importance. New developers may find it easier to start with a task that has a low priority as they often also have a lower difficulty.
Developers are encouraged to pick open tasks with high priorities according to their own skill level. Senior developers may directly assign tasks to developers based on their importance. New developers may find it easier to start with a task that has a low priority as they often also have a lower difficulty.
Open tasks can be found in the project overview: [PROJECT.md](https://github.com/Karaka-Management/Organization-Guide/blob/master/Project/PROJECT.md)
Open tasks can be found in the project overview: [Todos](https://github.com/orgs/Karaka-Management/projects/10)
Tasks currently in development are prefixed in the priority column with an asterisk `*` and a name tag in the task description of the developer who is working on the task.
Tasks currently in development are prefixed in the priority column with an asterisk `*` and a name tag in the task description of the developer who is working on the task.
The open tasks are reviewed once a month by a senior developer. The senior developer updates the project overview if necessary and requests feedback regarding development status of important tasks under development. During this process important tasks may also get directly assigned to developers. This review is performed on a judgmental bases of the senior basis.
The open tasks are reviewed once a month by a senior developer. The senior developer updates the project overview if necessary and requests feedback regarding development status of important tasks under development. During this process important tasks may also get directly assigned to developers. This review is performed on a judgmental bases of the senior basis.
### Code style
### Quality
Code changes must follow the [style guidelines](https://github.com/Karaka-Management/Developer-Guide/tree/develop/standards). Additionally, the automatic code style inspection tools must return no errors, failures or warnings. Developers should test their changes with inspection tools and configurations mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md) in advance before submitting them for review.
#### Code style
In rare cases errors, failures or warnings during the automatic inspection are acceptable. Reasons can be changes in the programming language, special cases which cannot, are difficult or must be individually configured in the inspection settings. If this is the case for a code change and if inspection configuration changes are necessary are decided by the senior developer performing the code review.
Code changes must follow the [style guidelines](https://github.com/Karaka-Management/Developer-Guide/tree/develop/standards) (**R3**). Additionally, the automatic code style inspection tools must return no errors, failures or warnings. Developers should test their changes with inspection tools and configurations mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md) in advance before submitting them for review. (**R4**)
Automated checks which are run during the review process:
In rare cases errors, failures or warnings during the automatic inspection are acceptable. Reasons can be for example special cases which are difficult automatize or must be individually configured in the inspection settings. If this is the case for a code change and if inspection configuration changes are necessary are decided by the senior developer performing the code review. (**R5**)
Automated checks which are run during the review process (**R4**):
```sh
php ./vendor/bin/phpcs --severity=1 ./ --standard="Build/Config/phpcs.xml"
php ./vendor/bin/phpcs ./ --standard="Build/Config/phpcs.xml"
php ./vendor/bin/php-cs-fixer fix ./ --config=Build/Config/.php-cs-fixer.php --allow-risky=yes
php ./vendor/bin/phpcbf --standard=Build/Config/phpcs.xml ./
php ./vendor/bin/rector process --dry-run --config Build/Config/rector.php ./
npx eslint ./ -c ./Build/Config/.eslintrc.json
```
### Tests
#### Tests
Code changes must follow the inspection guidelines (i.e. code coverage) mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md). Developers should check if the code changes comply with the inspection guidelines before submitting them.
Code changes must follow the inspection guidelines (i.e. code coverage) mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md) (**R6**). Developers should test their changes with inspection tools and configurations mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md) in advance before submitting them for review. (**R7**)
In rare cases it might be not possible to follow the inspection guidelines. In such cases the senior developer performing the code review may decide if the code change still gets accepted.
In rare cases it might be not possible to follow the inspection guidelines. In such cases the senior developer performing the code review may decide if the code change still gets accepted. (**R8**)
Automated tests which are run during the review process:
Automated tests which are run during the review process (**R7**):
```sh
php ./vendor/bin/phpunit -c tests/PHPUnit/phpunit_default.xml
php ./vendor/bin/phpstan analyse --autoload-file=phpOMS/Autoloader.php -l 9 -c Build/Config/phpstan.neon ./
php ./vendor/bin/phpstan analyse --no-progress -l 9 -c Build/Config/phpstan.neon ./
npx jasmine-node ./
./cOMS/tests/test.sh
```
Additional inspections which are run but might be ignored during the review depending on the use case are mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md) as other checks.
Additional inspections which are run but might be ignored during the review depending on the use case are mentioned in the [inspection documentation](https://github.com/Karaka-Management/Developer-Guide/blob/develop/quality/inspections.md) as other checks. (**R7**)
### Demo
#### Performance
Some code changes may also require changes or extensions in the demo setup scripts. The demo setup script try to simulate a real world use case by generating and modifying mostly random data. This is also a good way to setup and “manually” test the code changes in a larger picture. The demo setup script can be found in the [demoSetup](https://github.com/Karaka-Management/demoSetup) repository. The demo setup script takes a long time due to the large amount of user input simulated data which is generated. Therefore it is recommended to run this only sporadically.
Developers should occasionaly check performance statistics. At this point no target metrics are defined.
### Code review
Since the primary application is a web based application a similar tool as the Google lighthouse tool can be used to inspect the application for best practicies which can significantly improve the application performance. The sitespeed.io tool shows potential performance improvements and slow pages. With the php trace and profiler enabled in the `php.ini` file the VM automatically generates profiling and trace reports for every web request. These can be found in the webgrind logs directory and inspected in webgrind and dropped into the trace visualizer for a flame chart visualization. With mysqldumpslow you can inspect slow sql queries which may need optimization.
1. Automatic trace and benchmark generation with every web request in `/var/www/html/webgrind/Logs`
2. Webgrind view `http://vm_ip:82`
3. Trace visualization `http://vm_ip:81`
1. Download the latest trace from `http://vm_ip:82/Logs`
2. Drag and drop that downloaded `*.xt` file in the trace visualizer
4. `sitespeed.io ./Build/Helper/Scripts/sitespeedDemoUrls.txt -b chrome --outputFolder /var/www/html/sitespeed`
5. Slow query inspection.
```sh
mysqldumpslow -t 10 /var/log/mysql/mysql-slow.log
mysqldumpslow -t 10 -s l /var/log/mysql/mysql-slow.log
```
#### Code review
In addition to the automatic code review performed by the various inspection tools such as (phpcs, phpstan, phpunit, eslint and custom scripts) a senior developer must check the proposed code change before it is merged with the respective `develop` branch. Only upon the approval by the reviewer a code change requests gets merged as no other developers have permission in the software to make such code merges.
In case a code change request is not approved the reviewer states the reason for the decision, this may include some tips and requests which will allow the contributor to make improvements so that the code change may get approved.
If the code reviewer only finds minor issues with the proposed code change the reviewer may make small changes to the proposed code change and inform the contributor to speed up the implementation process. Code reviewers are encouraged to do this with new contributors to avoid long iteration processes and to not discourage new developers. However, communication is key and severe issues with code change requests or if the contributor already made multiple code change requests in the past the reviewer should not implement the improvements by himself and rather decline the code change requests with his reasoning.
If the code reviewer only finds minor issues with the proposed code change the reviewer may make small changes to the proposed code change and inform the contributor to speed up the implementation process. Code reviewers are encouraged to do this with new contributors to avoid long iteration processes and to not discourage new developers. However, communication is key and severe issues with code change requests or if the contributor already made multiple code change requests in the past the reviewer should not implement the improvements by himself and rather decline the code change requests with his reasoning. (**R5**+**R8**)
#### Demo
Some code changes may also require changes or extensions in the demo setup scripts. The demo setup script try to simulate a real world use case by generating and modifying mostly random data. This is also a good way to setup and “manually” test the code changes in a larger picture. The demo setup script can be found in the [demoSetup](https://github.com/Karaka-Management/demoSetup) repository. The demo setup script takes a long time due to the large amount of user input simulated data which is generated. Therefore it is recommended to run this only sporadically. (**R9**)
```sh
sudo -u www-data php -dxdebug.remote_enable=1 -dxdebug.start_with_request=yes -dxdebug.mode=coverage,develop,debug demoSetup/setup.php
```
#### Documentation
Occasionally new code or code changes also require new documentation or documentation changes. Developers should make sure that the new code is also reflected in the existing documentation ([Developer-Guide](), [User-Guide]() and/or module documentation) or if additional documentation is necessary.
#### Improvements, features, bugs
If a developer (or employee in general) has an idea for an improvement, feature or finds a potential bug it should be reported at https://github.com/Karaka-Management/Karaka/issues. A senior developer has to check these issues and decide how to proceed with them. The decision how to proceed with the issue must be explained by the senior developer as a response in the issue. Possible steps are:
* Accept the issue and put the task into the [Todos](https://github.com/orgs/Karaka-Management/projects/10)
* Dismiss the issue with an explanation
### Release flow
In case SCSS/CSS or JS files got changed they must get re-built locally before comitting the code change:
```sh
npx esbuild Web/Backend/js/backend.js --bundle --outfile=Install/Application/Backend/js/backend.min.js --minify
scss cssOMS/styles.scss > cssOMS/styles.css
```
For JS you may also use the shorthand command `npm run build`.
Code changes must be performed in a new branch. A new branch can be created with:
```sh
@ -75,8 +127,70 @@ git checkout -b new-branch-name
The name of the branch can be chosen freely however it is recommended to follow the following branch naming conventions:
* `feature-*` for feature implementations
* `hotfix-*` for security related fixes/improvements
* `bug-*` for bug fixes
* `security-*` for security related fixes/improvements
* `general-*` for general improvements (i.e. code documentation improvements, code style improvements)
* `general-*` for general improvements (i.e. documentation, code style & performance improvements)
The senior developer who performs the code review merges the change request into the `develop` branch upon approval.
```mermaid
%%{init: { 'gitGraph': {'mainBranchName': 'master'}} }%%
gitGraph
commit
branch hotfix-xxx
commit
checkout master
branch develop
checkout master
merge hotfix-xxx
checkout develop
branch bug-xxx
commit
commit
checkout hotfix-xxx
commit
checkout master
merge hotfix-xxx
checkout develop
merge bug-xxx
commit
checkout develop
branch feature-xxx
commit
commit
commit
checkout develop
merge feature-xxx
checkout master
merge develop
checkout develop
branch general-xxx
commit
checkout develop
merge general-xxx
branch security-xxx
commit
commit
checkout develop
merge security-xxx
checkout master
merge develop
```
The senior developer who performs the code review merges the change request into the `develop` branch after their successful code review. Unsuccessful reviews lead to change requests by the original developer, other developers who can make the requested changes, changes by the senior developer who performed the review, or dismissal of the changed code. (**R10**)
## Approved dependencies
### Customer dependencies
Developers may only rely on the dependencies defined in [Approved Customer Software]() when developing a solution. If new software should be added to this list or a different version is required developers should make a request with their team leader/head of department who forwards this requests if appropriate to the CTO and explain the reasoning for the different dependency needs. The CTO can decide if the dependency will be accepted. (**R11**)
### Developer dependencies
Developers may only rely on the dependencies defined in [IT Equipment & Software](). If new software should be added to this list or a different version is required developers should make a request with their team leader/head of department who forwards this requests if appropriate to the CTO and explain the reasoning for the different dependency needs. The CTO can decide if the dependency will be accepted. Changing the package managers such as `composer.json` or `package.json` is not allowed by anyone else than the CTO. (**R12**)
## Other related documents
* [Confidentiality Policy](../Policies%20&%20Guidelines/Confidentiality%20Policy.md)
* [Organization Activity Policy](../Policies%20&%20Guidelines/Organization%20Activity%20Policy.md)
* [Tutorials](./Development/Tutorials)

View File

@ -45,12 +45,12 @@ interface SettingsInterface extends OptionsInterface
*/
public function get(
mixed $ids = null,
string | array $names = null,
int $unit = null,
int $app = null,
string $module = null,
int $group = null,
int $account = null
string | array|null $names = null,
?int $unit = null,
?int $app = null,
?string $module = null,
?int $group = null,
?int $account = null
) : mixed;
/**

View File

@ -51,7 +51,7 @@ interface StreamInterface
*
* @since 1.0.0
*/
public function getMetaData(string $key = null);
public function getMetaData(?string $key = null);
/**
* Get the stream resource
@ -72,7 +72,7 @@ interface StreamInterface
*
* @since 1.0.0
*/
public function setStream($stream, int $size = null) : self;
public function setStream($stream, ?int $size = null) : self;
/**
* Detach the current stream resource
@ -264,7 +264,7 @@ interface StreamInterface
*
* @since 1.0.0
*/
public function readLine(int $maxLength = null) : ?string;
public function readLine(?int $maxLength = null) : ?string;
/**
* Set custom data on the stream

View File

@ -94,7 +94,7 @@ final class FileCache extends ConnectionAbstract
*
* @since 1.0.0
*/
public function connect(array $data = null) : void
public function connect(?array $data = null) : void
{
$this->dbdata = $data;

View File

@ -74,7 +74,7 @@ final class MemCached extends ConnectionAbstract
*
* @since 1.0.0
*/
public function connect(array $data = null) : void
public function connect(?array $data = null) : void
{
$this->dbdata = isset($data) ? $data : $this->dbdata;

View File

@ -27,7 +27,7 @@ final class NullCache extends ConnectionAbstract
/**
* {@inheritdoc}
*/
public function connect(array $data = null) : void
public function connect(?array $data = null) : void
{
}

View File

@ -66,7 +66,7 @@ final class RedisCache extends ConnectionAbstract
*
* @since 1.0.0
*/
public function connect(array $data = null) : void
public function connect(?array $data = null) : void
{
$this->dbdata = isset($data) ? $data : $this->dbdata;

View File

@ -33,7 +33,7 @@ final class InvalidConnectionConfigException extends \InvalidArgumentException
*
* @since 1.0.0
*/
public function __construct(string $message = '', int $code = 0, \Exception $previous = null)
public function __construct(string $message = '', int $code = 0, ?\Exception $previous = null)
{
parent::__construct('Invalid/missing config value for "' . $message . '".', $code, $previous);
}

View File

@ -99,7 +99,7 @@ final class CookieJar
mixed $value,
int $expire = 86400,
string $path = '/',
string $domain = null,
?string $domain = null,
bool $secure = false,
bool $httpOnly = true,
bool $overwrite = true

View File

@ -35,7 +35,7 @@ interface DataStorageConnectionInterface
*
* @since 1.0.0
*/
public function connect(array $data = null) : void;
public function connect(?array $data = null) : void;
/**
* Get the datastorage type.

View File

@ -35,14 +35,6 @@ abstract class BuilderAbstract
*/
protected bool $isReadOnly = false;
/**
* Grammar.
*
* @var GrammarAbstract
* @since 1.0.0
*/
protected GrammarAbstract $grammar;
/**
* Database connection.
*

View File

@ -161,7 +161,7 @@ abstract class ConnectionAbstract implements ConnectionInterface
*
* @since 1.0.0
*/
abstract public function connect(array $dbdata = null) : void;
abstract public function connect(?array $dbdata = null) : void;
/**
* Object destructor.

View File

@ -58,7 +58,7 @@ final class MysqlConnection extends ConnectionAbstract
/**
* {@inheritdoc}
*/
public function connect(array $dbdata = null) : void
public function connect(?array $dbdata = null) : void
{
if ($this->status === DatabaseStatus::OK) {
return;

View File

@ -27,7 +27,7 @@ final class NullConnection extends ConnectionAbstract
/**
* {@inheritdoc}
*/
public function connect(array $dbdata = null) : void
public function connect(?array $dbdata = null) : void
{
}

View File

@ -58,7 +58,7 @@ final class PostgresConnection extends ConnectionAbstract
/**
* {@inheritdoc}
*/
public function connect(array $dbdata = null) : void
public function connect(?array $dbdata = null) : void
{
if ($this->status === DatabaseStatus::OK) {
return;

View File

@ -66,7 +66,7 @@ final class SQLiteConnection extends ConnectionAbstract
*
* @since 1.0.0
*/
public function connect(array $dbdata = null) : void
public function connect(?array $dbdata = null) : void
{
if ($this->status === DatabaseStatus::OK) {
return;

View File

@ -58,7 +58,7 @@ final class SqlServerConnection extends ConnectionAbstract
/**
* {@inheritdoc}
*/
public function connect(array $dbdata = null) : void
public function connect(?array $dbdata = null) : void
{
if ($this->status === DatabaseStatus::OK) {
return;

View File

@ -33,7 +33,7 @@ final class InvalidConnectionConfigException extends \InvalidArgumentException
*
* @since 1.0.0
*/
public function __construct(string $message = '', int $code = 0, \Exception $previous = null)
public function __construct(string $message = '', int $code = 0, ?\Exception $previous = null)
{
parent::__construct('Missing config value for "' . $message . '".', $code, $previous);
}

View File

@ -33,7 +33,7 @@ final class InvalidDatabaseTypeException extends \InvalidArgumentException
*
* @since 1.0.0
*/
public function __construct(string $message = '', int $code = 0, \Exception $previous = null)
public function __construct(string $message = '', int $code = 0, ?\Exception $previous = null)
{
parent::__construct('Invalid database type "' . $message . '".', $code, $previous);
}

View File

@ -33,7 +33,7 @@ final class InvalidMapperException extends \RuntimeException
*
* @since 1.0.0
*/
public function __construct(string $message = '', int $code = 0, \Exception $previous = null)
public function __construct(string $message = '', int $code = 0, ?\Exception $previous = null)
{
if ($message === '') {
parent::__construct('Empty mapper.', $code, $previous);

View File

@ -15,7 +15,7 @@ declare(strict_types=1);
namespace phpOMS\DataStorage\Database;
use phpOMS\Contract\SerializableInterface;
use phpOMS\DataStorage\Database\Query\Column;
use phpOMS\DataStorage\Database\Query\ColumnName;
use phpOMS\DataStorage\Database\Query\Parameter;
/**
@ -60,22 +60,6 @@ abstract class GrammarAbstract
*/
public string $systemIdentifierEnd = '"';
/**
* And operator.
*
* @var string
* @since 1.0.0
*/
public string $and = 'AND';
/**
* Or operator.
*
* @var string
* @since 1.0.0
*/
public string $or = 'OR';
/**
* Special keywords.
*
@ -87,9 +71,6 @@ abstract class GrammarAbstract
'MAX(',
'MIN(',
'SUM(',
'DATE(',
'YEAR(',
'MONTH(',
];
/**
@ -115,30 +96,7 @@ abstract class GrammarAbstract
}
/**
* Compile to query.
*
* @param BuilderAbstract $query Builder
*
* @return string
*
* @since 1.0.0
*/
public function compileQuery(BuilderAbstract $query) : string
{
$components = $this->compileComponents($query);
$queryString = '';
foreach ($components as $component) {
if ($component !== '') {
$queryString .= $component . ' ';
}
}
return \substr($queryString, 0, -1) . ';';
}
/**
* Compile post querys.
* Compile post queries.
*
* These are queries, which should be run after the main query (e.g. table alters, trigger definitions etc.)
*
@ -148,7 +106,7 @@ abstract class GrammarAbstract
*
* @since 1.0.0
*/
public function compilePostQuerys(BuilderAbstract $query) : array
public function compilePostQueries(BuilderAbstract $query) : array
{
return [];
}
@ -164,7 +122,7 @@ abstract class GrammarAbstract
*
* @since 1.0.0
*/
abstract protected function compileComponents(BuilderAbstract $query) : array;
abstract public function compileComponents(BuilderAbstract $query) : array;
/**
* Get date format.
@ -190,20 +148,20 @@ abstract class GrammarAbstract
*
* @since 1.0.0
*/
protected function expressionizeTableColumn(array $elements, bool $column = true) : string
public function expressionizeTableColumn(array $elements, bool $column = true) : string
{
$expression = '';
foreach ($elements as $key => $element) {
if (\is_string($element)) {
$expression .= $this->compileSystem($element)
. (\is_string($key) ? ' as ' . $key : '') . ', ';
} elseif ($element instanceof \Closure) {
$expression .= $element() . (\is_string($key) ? ' as ' . $key : '') . ', ';
} elseif ($element instanceof BuilderAbstract) {
$expression .= $element->toSql() . (\is_string($key) ? ' as ' . $key : '') . ', ';
$expression .= $this->compileSystem($element) . (\is_string($key) ? ' AS ' . $key : '') . ', ';
} elseif (\is_int($element)) {
// example: select 1
$expression .= $element . ', ';
} elseif ($element instanceof BuilderAbstract) {
$expression .= $element->toSql() . (\is_string($key) ? ' AS ' . $key : '') . ', ';
} elseif ($element instanceof \Closure) {
$expression .= $element() . (\is_string($key) ? ' AS ' . $key : '') . ', ';
} else {
throw new \InvalidArgumentException();
}
@ -237,9 +195,9 @@ abstract class GrammarAbstract
$identifierEnd = '';
} elseif ((\stripos($system, '.')) !== false) {
// This is actually slower than \explode(), despite knowing the first index
//$split = [\substr($system, 0, $pos), \substr($system, $pos + 1)];
// $split = [\substr($system, 0, $pos), \substr($system, $pos + 1)];
// Faster! But might requires more memory?
// Faster! But might require more memory?
$split = \explode('.', $system);
$identifierTwoStart = $identifierStart;
@ -294,8 +252,8 @@ abstract class GrammarAbstract
return (string) ((int) $value);
} elseif (\is_float($value)) {
return \rtrim(\rtrim(\number_format($value, 5, '.', ''), '0'), '.');
} elseif ($value instanceof Column) {
return '(' . \rtrim($this->compileColumnQuery($value), ';') . ')';
} elseif ($value instanceof ColumnName) {
return $this->compileSystem($value->name);
} elseif ($value instanceof BuilderAbstract) {
return '(' . \rtrim($value->toSql(), ';') . ')';
} elseif ($value instanceof \JsonSerializable) {
@ -310,18 +268,4 @@ abstract class GrammarAbstract
throw new \InvalidArgumentException(\gettype($value));
}
}
/**
* Compile column query.
*
* @param Column $column Where query
*
* @return string
*
* @since 1.0.0
*/
protected function compileColumnQuery(Column $column) : string
{
return $column->toSql();
}
}

View File

@ -56,13 +56,28 @@ abstract class DataMapperAbstract
*/
protected int $depth = 1;
/**
* Mapper join alias.
*
* Mappers may have relations to other models (e.g. belongsTo, ownsOne) which can have other relations, ...
* If a mapper relates to the same model multiple times e.g. createdBy and lastModifiedBy we need to create
* separate joins because both could have different relations. However $depth only differentiates for
* different relation depth, not when the same table is referenced on the same depth/level.
*
* With the join alias we can reference the same table multiple times in a join!
*
* @var int
* @since 1.0.0
*/
protected string $joinAlias = '';
/**
* Relations which should be loaded
*
* @var array
* @since 1.0.0
*/
protected array $with = [];
public array $with = [];
/**
* Sort order
@ -143,6 +158,15 @@ abstract class DataMapperAbstract
$this->db = $db;
}
protected string $indexedBy = '';
public function indexedBy(string $index) : self
{
$this->indexedBy = $index;
return $this;
}
/**
* Define a query which is merged with the internal query generation.
*
@ -152,7 +176,7 @@ abstract class DataMapperAbstract
*
* @since 1.0.0
*/
public function query(Builder $query = null) : self
public function query(?Builder $query = null) : self
{
$this->query = $query;

View File

@ -108,14 +108,6 @@ class DataMapperFactory
*/
public const TABLE = '';
/**
* Parent column.
*
* @var class-string
* @since 1.0.0
*/
public const PARENT = '';
/**
* Model to use by the mapper.
*
@ -187,7 +179,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function reader(ConnectionAbstract $db = null) : ReadMapper
public static function reader(?ConnectionAbstract $db = null) : ReadMapper
{
return new ReadMapper(new static(), $db ?? self::$db);
}
@ -201,7 +193,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function get(ConnectionAbstract $db = null) : ReadMapper
public static function get(?ConnectionAbstract $db = null) : ReadMapper
{
/** @var ReadMapper<T> $reader */
$reader = new ReadMapper(new static(), $db ?? self::$db);
@ -218,7 +210,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function yield(ConnectionAbstract $db = null) : ReadMapper
public static function yield(?ConnectionAbstract $db = null) : ReadMapper
{
/** @var ReadMapper<T> $reader */
$reader = new ReadMapper(new static(), $db ?? self::$db);
@ -235,7 +227,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function getRaw(ConnectionAbstract $db = null) : ReadMapper
public static function getRaw(?ConnectionAbstract $db = null) : ReadMapper
{
/** @var ReadMapper<T> $reader */
$reader = new ReadMapper(new static(), $db ?? self::$db);
@ -252,7 +244,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function getRandom(ConnectionAbstract $db = null) : ReadMapper
public static function getRandom(?ConnectionAbstract $db = null) : ReadMapper
{
return (new ReadMapper(new static(), $db ?? self::$db))->getRandom();
}
@ -266,7 +258,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function count(ConnectionAbstract $db = null) : ReadMapper
public static function count(?ConnectionAbstract $db = null) : ReadMapper
{
return (new ReadMapper(new static(), $db ?? self::$db))->count();
}
@ -280,7 +272,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function sum(ConnectionAbstract $db = null) : ReadMapper
public static function sum(?ConnectionAbstract $db = null) : ReadMapper
{
return (new ReadMapper(new static(), $db ?? self::$db))->sum();
}
@ -294,7 +286,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function exists(ConnectionAbstract $db = null) : ReadMapper
public static function exists(?ConnectionAbstract $db = null) : ReadMapper
{
return (new ReadMapper(new static(), $db ?? self::$db))->exists();
}
@ -308,7 +300,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function has(ConnectionAbstract $db = null) : ReadMapper
public static function has(?ConnectionAbstract $db = null) : ReadMapper
{
return (new ReadMapper(new static(), $db ?? self::$db))->has();
}
@ -322,7 +314,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function getQuery(ConnectionAbstract $db = null) : Builder
public static function getQuery(?ConnectionAbstract $db = null) : Builder
{
return (new ReadMapper(new static(), $db ?? self::$db))->getQuery();
}
@ -336,7 +328,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function getAll(ConnectionAbstract $db = null) : ReadMapper
public static function getAll(?ConnectionAbstract $db = null) : ReadMapper
{
/** @var ReadMapper<T> $reader */
$reader = new ReadMapper(new static(), $db ?? self::$db);
@ -353,7 +345,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function writer(ConnectionAbstract $db = null) : WriteMapper
public static function writer(?ConnectionAbstract $db = null) : WriteMapper
{
return new WriteMapper(new static(), $db ?? self::$db);
}
@ -367,7 +359,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function create(ConnectionAbstract $db = null) : WriteMapper
public static function create(?ConnectionAbstract $db = null) : WriteMapper
{
return (new WriteMapper(new static(), $db ?? self::$db))->create();
}
@ -381,7 +373,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function updater(ConnectionAbstract $db = null) : UpdateMapper
public static function updater(?ConnectionAbstract $db = null) : UpdateMapper
{
return new UpdateMapper(new static(), $db ?? self::$db);
}
@ -395,7 +387,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function update(ConnectionAbstract $db = null) : UpdateMapper
public static function update(?ConnectionAbstract $db = null) : UpdateMapper
{
return (new UpdateMapper(new static(), $db ?? self::$db))->update();
}
@ -409,7 +401,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function remover(ConnectionAbstract $db = null) : DeleteMapper
public static function remover(?ConnectionAbstract $db = null) : DeleteMapper
{
return new DeleteMapper(new static(), $db ?? self::$db);
}
@ -423,7 +415,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function delete(ConnectionAbstract $db = null) : DeleteMapper
public static function delete(?ConnectionAbstract $db = null) : DeleteMapper
{
return (new DeleteMapper(new static(), $db ?? self::$db))->delete();
}
@ -471,7 +463,7 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function createBaseModel(array $data = null) : object
public static function createBaseModel(?array $data = null) : object
{
if (empty(static::FACTORY)) {
$class = empty(static::MODEL) ? \substr(static::class, 0, -6) : static::MODEL;
@ -482,6 +474,25 @@ class DataMapperFactory
return static::FACTORY::createWith($data);
}
public static function hasFactory() : bool
{
return !empty(static::FACTORY);
}
/**
* Create the empty base model
*
* @param null|array $data Data to use for initialization
*
* @return string
*
* @since 1.0.0
*/
public static function getBaseModelClass() : string
{
return empty(static::MODEL) ? \substr(static::class, 0, -6) : static::MODEL;
}
/**
* Get id of object
*
@ -493,14 +504,12 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function getObjectId(object $obj, string $member = null, \ReflectionClass &$refClass = null) : mixed
public static function getObjectId(object $obj, ?string $member = null, ?\ReflectionClass &$refClass = null) : mixed
{
$propertyName = $member ?? static::COLUMNS[static::PRIMARYFIELD]['internal'];
if (static::COLUMNS[static::PRIMARYFIELD]['private'] ?? false) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$refProp = $refClass->getProperty($propertyName);
@ -521,15 +530,13 @@ class DataMapperFactory
*
* @since 1.0.0
*/
public static function setObjectId(object $obj, mixed $objId, \ReflectionClass &$refClass = null) : void
public static function setObjectId(object $obj, mixed $objId, ?\ReflectionClass &$refClass = null) : void
{
$propertyName = static::COLUMNS[static::PRIMARYFIELD]['internal'];
\settype($objId, static::COLUMNS[static::PRIMARYFIELD]['type']);
if (static::COLUMNS[static::PRIMARYFIELD]['private'] ?? false) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$refProp = $refClass->getProperty($propertyName);
$refProp->setValue($obj, $objId);
@ -569,7 +576,7 @@ class DataMapperFactory
* @param int $pageLimit Limit result set
* @param string $sortBy Model member name to sort by
* @param string $sortOrder Sort order
* @param array $searchFields Fields to search in. ([] = all) @todo: maybe change to all which have autocomplete = true defined?
* @param array $searchFields Fields to search in. ([] = all) @todo maybe change to all which have autocomplete = true defined?
* @param array $filters Additional search filters applied ['type', 'value1', 'logic1', 'value2', 'logic2']
*
* @return array{hasPrevious:bool, hasNext:bool, data:object[]}
@ -577,18 +584,18 @@ class DataMapperFactory
* @since 1.0.0
*/
public static function find(
string $search = null,
DataMapperAbstract $mapper = null,
?string $search = null,
?DataMapperAbstract $mapper = null,
int $id = 0,
string $secondaryId = '',
string $type = null,
?string $type = null,
int $pageLimit = 25,
string $sortBy = null,
?string $sortBy = null,
string $sortOrder = OrderType::DESC,
array $searchFields = [],
array $filters = []
) : array {
$mapper ??= static::getAll();
$mapper ??= static::getAll();
$sortOrder = \strtoupper($sortOrder);
$data = [];
@ -636,7 +643,7 @@ class DataMapperFactory
}
}
// @todo: how to handle columns which are NOT members (columns which are manipulated)
// @todo how to handle columns which are NOT members (columns which are manipulated)
// Maybe pass callback array which can handle these cases?
if ($type === 'p') {
@ -688,26 +695,15 @@ class DataMapperFactory
--$count;
}
} else {
if (\reset($data)->getId() === $id) {
if (\reset($data)->id === $id) {
\array_shift($data);
$hasNext = true;
--$count;
}
if ($count > $pageLimit) {
// @todo: can be maybe removed?
/*
if (!$hasNext) {
\array_pop($data);
$hasNext = true;
--$count;
}
*/
if ($count > $pageLimit) {
$hasPrevious = true;
\array_pop($data);
}
$hasPrevious = true;
\array_pop($data);
}
$data = \array_reverse($data);
@ -742,7 +738,7 @@ class DataMapperFactory
];
}
if (\reset($data)->getId() === $id) {
if (\reset($data)->id === $id) {
\array_shift($data);
$hasPrevious = true;
--$count;

View File

@ -78,10 +78,10 @@ final class DeleteMapper extends DataMapperAbstract
return null;
}
$this->deleteSingleRelation($obj, $this->mapper::BELONGS_TO, $refClass);
$this->deleteSingleRelation($obj, $this->mapper::OWNS_ONE, $refClass);
$this->deleteHasMany($obj, $objId, $refClass);
$this->deleteModel($objId);
$this->deleteSingleRelation($obj, $this->mapper::OWNS_ONE, $refClass);
$this->deleteSingleRelation($obj, $this->mapper::BELONGS_TO, $refClass);
return $objId;
}
@ -119,7 +119,7 @@ final class DeleteMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
private function deleteSingleRelation(object $obj, array $relation, \ReflectionClass &$refClass = null) : void
private function deleteSingleRelation(object $obj, array $relation, ?\ReflectionClass &$refClass = null) : void
{
if (empty($relation)) {
return;
@ -141,9 +141,7 @@ final class DeleteMapper extends DataMapperAbstract
$value = null;
if ($isPrivate) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$refProp = $refClass->getProperty($member);
$value = $refProp->getValue($obj);
@ -166,14 +164,13 @@ final class DeleteMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
private function deleteHasMany(object $obj, mixed $objId, \ReflectionClass &$refClass = null) : void
private function deleteHasMany(object $obj, mixed $objId, ?\ReflectionClass &$refClass = null) : void
{
if (empty($this->mapper::HAS_MANY)) {
return;
}
foreach ($this->mapper::HAS_MANY as $member => $rel) {
// always
if (!isset($this->with[$member]) && !isset($rel['external'])) {
continue;
}
@ -183,9 +180,7 @@ final class DeleteMapper extends DataMapperAbstract
$values = null;
if ($isPrivate) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$refProp = $refClass->getProperty($member);
$values = $refProp->getValue($obj);
@ -237,7 +232,7 @@ final class DeleteMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
public function deleteRelationTable(string $member, array $objIds = null, mixed $objId) : void
public function deleteRelationTable(string $member, ?array $objIds = null, mixed $objId) : void
{
if ((empty($objIds) && $objIds !== null)
|| $this->mapper::HAS_MANY[$member]['table'] === $this->mapper::TABLE
@ -252,7 +247,7 @@ final class DeleteMapper extends DataMapperAbstract
->where($this->mapper::HAS_MANY[$member]['table'] . '.' . $this->mapper::HAS_MANY[$member]['self'], '=', $objId);
if ($objIds !== null) {
$relQuery->where($this->mapper::HAS_MANY[$member]['table'] . '.' . $this->mapper::HAS_MANY[$member]['external'], 'in', $objIds);
$relQuery->where($this->mapper::HAS_MANY[$member]['table'] . '.' . $this->mapper::HAS_MANY[$member]['external'], 'IN', $objIds);
}
$sth = $this->db->con->prepare($relQuery->toSql());

View File

@ -38,6 +38,13 @@ abstract class MapperType extends Enum
public const GET_RANDOM = 11;
// @IMPORTANT All read operations which use column names must have an ID < 12
// In the read mapper exists a line which checks for < COUNT_MODELS to decide if columns should be selected.
// The reason for this is that **pure** count, sum, ... don't want to select additional column names.
// By doing this we avoid loading all the unwanted columns coming from the `with()` relation.
// -------------------------------------------- //
public const COUNT_MODELS = 12;
public const SUM_MODELS = 13;

File diff suppressed because it is too large Load Diff

View File

@ -102,7 +102,7 @@ final class UpdateMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
private function updateModel(object $obj, mixed $objId, \ReflectionClass &$refClass = null) : void
private function updateModel(object $obj, mixed $objId, ?\ReflectionClass &$refClass = null) : void
{
try {
// Model doesn't have anything to update
@ -115,7 +115,10 @@ final class UpdateMapper extends DataMapperAbstract
->where($this->mapper::TABLE . '.' . $this->mapper::PRIMARYFIELD, '=', $objId);
foreach ($this->mapper::COLUMNS as $column) {
$propertyName = \stripos($column['internal'], '/') !== false ? \explode('/', $column['internal'])[0] : $column['internal'];
$propertyName = \stripos($column['internal'], '/') !== false
? \explode('/', $column['internal'])[0]
: $column['internal'];
if (isset($this->mapper::HAS_MANY[$propertyName])
|| $column['internal'] === $this->mapper::PRIMARYFIELD
|| (($column['readonly'] ?? false) && !isset($this->with[$propertyName]))
@ -129,9 +132,7 @@ final class UpdateMapper extends DataMapperAbstract
$tValue = null;
if ($isPrivate) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$property = $refClass->getProperty($propertyName);
$tValue = $property->getValue($obj);
@ -161,9 +162,6 @@ final class UpdateMapper extends DataMapperAbstract
}
}
// @todo:
// @bug: Sqlite doesn't allow table_name.column_name in set queries for whatver reason.
$sth = $this->db->con->prepare($query->toSql());
if ($sth !== false) {
$sth->execute();
@ -196,6 +194,14 @@ final class UpdateMapper extends DataMapperAbstract
/** @var class-string<DataMapperFactory> $mapper */
$mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper'];
if (!isset($this->with[$propertyName])) {
$id = $mapper::getObjectId($obj);
return empty($id) && $mapper::isNullModel($obj)
? null
: $id;
}
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName);
$relMapper->depth = $this->depth + 1;
@ -218,6 +224,14 @@ final class UpdateMapper extends DataMapperAbstract
/** @var class-string<DataMapperFactory> $mapper */
$mapper = $this->mapper::OWNS_ONE[$propertyName]['mapper'];
if (!isset($this->with[$propertyName])) {
$id = $mapper::getObjectId($obj);
return empty($id) && $mapper::isNullModel($obj)
? null
: $id;
}
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName);
$relMapper->depth = $this->depth + 1;
@ -238,7 +252,7 @@ final class UpdateMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
private function updateHasMany(object $obj, mixed $objId, \ReflectionClass &$refClass = null) : void
private function updateHasMany(object $obj, mixed $objId, ?\ReflectionClass &$refClass = null) : void
{
if (empty($this->with) || empty($this->mapper::HAS_MANY)) {
return;
@ -260,9 +274,7 @@ final class UpdateMapper extends DataMapperAbstract
$values = null;
if ($isPrivate) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$property = $refClass->getProperty($propertyName);
$values = $property->getValue($obj);
@ -346,7 +358,7 @@ final class UpdateMapper extends DataMapperAbstract
$query = new Builder($this->db);
$src = $many['external'] ?? $many['mapper']::PRIMARYFIELD;
// @todo: what if a specific column name is defined instead of primaryField for the join? Fix, it should be stored in 'column'
// @todo what if a specific column name is defined instead of primaryField for the join? Fix, it should be stored in 'column'
$query->select($many['table'] . '.' . $src)
->from($many['table'])
->where($many['table'] . '.' . $many['self'], '=', $objId);
@ -375,7 +387,7 @@ final class UpdateMapper extends DataMapperAbstract
$this->mapper::remover(db: $this->db)->deleteRelationTable($member, $removes, $objId);
}
if (!empty($adds)) {
if (!empty($adds) && isset($this->mapper::HAS_MANY[$member]['external'])) {
$this->mapper::writer(db: $this->db)->createRelationTable($member, $adds, $objId);
}
}

View File

@ -26,6 +26,9 @@ use phpOMS\Utils\ArrayUtils;
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @todo Lock data for concurrency (e.g. table row lock or heartbeat)
* https://github.com/Karaka-Management/Karaka/issues/152
*/
final class WriteMapper extends DataMapperAbstract
{
@ -74,20 +77,16 @@ final class WriteMapper extends DataMapperAbstract
*/
public function executeCreate(object $obj) : mixed
{
$refClass = null;
if ($this->mapper::isNullModel($obj)) {
$objId = $this->mapper::getObjectId($obj);
$objId = $this->mapper::getObjectId($obj);
if ((!empty($objId) && $this->mapper::AUTOINCREMENT)
|| $this->mapper::isNullModel($obj)
) {
return $objId === 0 ? null : $objId;
}
if (!empty($id = $this->mapper::getObjectId($obj)) && $this->mapper::AUTOINCREMENT) {
$objId = $id;
} else {
$objId = $this->createModel($obj, $refClass);
$this->mapper::setObjectId($obj, $objId, $refClass);
}
$refClass = null;
$objId = $this->createModel($obj, $refClass);
$this->mapper::setObjectId($obj, $objId, $refClass);
$this->createHasMany($obj, $objId, $refClass);
@ -104,7 +103,7 @@ final class WriteMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
private function createModel(object $obj, \ReflectionClass &$refClass = null) : mixed
private function createModel(object $obj, ?\ReflectionClass &$refClass = null) : mixed
{
try {
$query = new Builder($this->db);
@ -123,9 +122,7 @@ final class WriteMapper extends DataMapperAbstract
$tValue = null;
if ($column['private'] ?? false) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$property = $refClass->getProperty($propertyName);
$tValue = $property->getValue($obj);
@ -231,7 +228,6 @@ final class WriteMapper extends DataMapperAbstract
if (isset($this->mapper::BELONGS_TO[$propertyName]['by'])) {
// has by (obj is stored as a different model e.g. model = profile but reference/db is account)
if ($this->mapper::BELONGS_TO[$propertyName]['private'] ?? false) {
$refClass = new \ReflectionClass($obj);
$refProp = $refClass->getProperty($this->mapper::BELONGS_TO[$propertyName]['by']);
@ -245,8 +241,11 @@ final class WriteMapper extends DataMapperAbstract
$mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper'];
$primaryKey = $mapper::getObjectId($obj);
// @todo: the $mapper::create() might cause a problem if 'by' is set. because we don't want to create this obj but the child obj.
return empty($primaryKey) ? $mapper::create(db: $this->db)->execute($obj) : $primaryKey;
// @bug The $mapper::create() might cause a problem if 'by' is set.
// This is because we don't want to create this obj but the child obj.
return empty($primaryKey)
? $mapper::create(db: $this->db)->execute($obj)
: $primaryKey;
}
/**
@ -262,7 +261,7 @@ final class WriteMapper extends DataMapperAbstract
*
* @since 1.0.0
*/
private function createHasMany(object $obj, mixed $objId, \ReflectionClass &$refClass = null) : void
private function createHasMany(object $obj, mixed $objId, ?\ReflectionClass &$refClass = null) : void
{
foreach ($this->mapper::HAS_MANY as $propertyName => $rel) {
if (!isset($this->mapper::HAS_MANY[$propertyName]['mapper'])) {
@ -274,9 +273,7 @@ final class WriteMapper extends DataMapperAbstract
$values = null;
if ($isPrivate) {
if ($refClass === null) {
$refClass = new \ReflectionClass($obj);
}
$refClass ??= new \ReflectionClass($obj);
$property = $refClass->getProperty($propertyName);
$values = $property->getValue($obj);
@ -286,16 +283,12 @@ final class WriteMapper extends DataMapperAbstract
/** @var class-string<DataMapperFactory> $mapper */
$mapper = $this->mapper::HAS_MANY[$propertyName]['mapper'];
$internalName = isset($mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']])
? $mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['internal']
: 'ERROR';
// @todo: this or $isRelPrivate is wrong, don't know which one.
$isInternalPrivate =$mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['private'] ?? false;
$internalName = $mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['internal'] ?? 'ERROR-BAD-SELF';
$isRelPrivate = $mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['private'] ?? false;
if (\is_object($values)) {
// conditionals
if ($isInternalPrivate) {
if ($isRelPrivate) {
$relReflectionClass = new \ReflectionClass($values);
$relProperty = $relReflectionClass->getProperty($internalName);
@ -306,14 +299,13 @@ final class WriteMapper extends DataMapperAbstract
$mapper::create(db: $this->db)->execute($values);
continue;
} elseif (!\is_array($values)) {
// @todo: conditionals???
} elseif (!\is_array($values) || empty($values)) {
// @todo conditionals?
continue;
}
$objsIds = [];
$isRelPrivate = $mapper::COLUMNS[$this->mapper::HAS_MANY[$propertyName]['self']]['private'] ?? false;
$relReflectionClass = $isRelPrivate && !empty($values) ? new \ReflectionClass(\reset($values)) : null;
$relReflectionClass = $isRelPrivate ? new \ReflectionClass(\reset($values)) : null;
foreach ($values as $key => $value) {
if (!\is_object($value)) {
@ -339,14 +331,13 @@ final class WriteMapper extends DataMapperAbstract
$relProperty = $relReflectionClass->getProperty($internalName);
}
// @todo maybe consider to just set the column type to object, and then check for that (might be faster)
if (isset($mapper::BELONGS_TO[$internalName])
|| isset($mapper::OWNS_ONE[$internalName])
) {
if ($isRelPrivate) {
$relProperty->setValue($value, $this->mapper::createNullModel($objId));
} else {
$value->{$internalName} = $this->mapper::createNullModel($objId);
$value->{$internalName} = $this->mapper::createNullModel($objId);
}
} elseif ($isRelPrivate) {
$relProperty->setValue($value, $objId);
@ -355,10 +346,18 @@ final class WriteMapper extends DataMapperAbstract
}
}
// @performance This inserts one element at a time. SQL allows to insert multiple rows.
// The problem with this is, that we then need to manually calculate the objIds
// since lastInsertId returns the first generated id.
// However, the current use case in Jingga only rarely has multiple hasMany during the creation
// since we are calling the API individually.
// https://github.com/Karaka-Management/phpOMS/issues/370
$objsIds[$key] = $mapper::create(db: $this->db)->execute($value);
}
$this->createRelationTable($propertyName, $objsIds, $objId);
if (!empty($objsIds) && isset($this->mapper::HAS_MANY[$propertyName]['external'])) {
$this->createRelationTable($propertyName, $objsIds, $objId);
}
}
}
@ -376,9 +375,11 @@ final class WriteMapper extends DataMapperAbstract
public function createRelationTable(string $propertyName, array $objsIds, mixed $objId) : void
{
try {
/* This check got pulled out to avoid function call to begin with
if (empty($objsIds) || !isset($this->mapper::HAS_MANY[$propertyName]['external'])) {
return;
}
*/
$relQuery = new Builder($this->db);
$relQuery->into($this->mapper::HAS_MANY[$propertyName]['table'])

View File

@ -18,6 +18,7 @@ use phpOMS\Algorithm\Graph\DependencyResolver;
use phpOMS\Contract\SerializableInterface;
use phpOMS\DataStorage\Database\BuilderAbstract;
use phpOMS\DataStorage\Database\Connection\ConnectionAbstract;
use phpOMS\DataStorage\Database\Query\Grammar\Grammar;
/**
* Database query builder.
@ -26,9 +27,22 @@ use phpOMS\DataStorage\Database\Connection\ConnectionAbstract;
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*
* @question Consider to delete the builder but create a Select, Insert, ... builder
* Then directly call the compileSelect + compileFrom ... from the toSql
* This way the object generated would be much slimmer since we don't need to initialize empty data for
* Insert etc. We also wouldn't have to call compileComponents since this would happen directly in toSql().
*/
class Builder extends BuilderAbstract
{
/**
* Grammar.
*
* @var Grammar
* @since 1.0.0
*/
protected Grammar $grammar;
/**
* Log queries.
*
@ -62,7 +76,9 @@ class Builder extends BuilderAbstract
public array $updates = [];
/**
* Stupid work around because value needs to be not null for it to work in Grammar.
* Deletes.
*
* @todo Find fix for stupid work around because value needs to be not null for it to work in Grammar.
*
* @var array
* @since 1.0.0
@ -231,6 +247,8 @@ class Builder extends BuilderAbstract
'similar to',
'not similar to',
'in',
'exists',
'not exists',
];
/**
@ -352,7 +370,16 @@ class Builder extends BuilderAbstract
$this->resolveJoinDependencies();
}
$query = $this->grammar->compileQuery($this);
$components = $this->grammar->compileComponents($this);
$queryString = '';
foreach ($components as $component) {
if ($component !== '') {
$queryString .= $component . ' ';
}
}
$query = \substr($queryString, 0, -1) . ';';
if (self::$log) {
\phpOMS\Log\FileLogger::getInstance()->debug($query);
@ -391,13 +418,13 @@ class Builder extends BuilderAbstract
}
// add from to existing dependencies
foreach ($this->from as $table => $from) {
foreach ($this->from as $table => $_) {
$dependencies[$table] = [];
}
$resolved = DependencyResolver::resolve($dependencies);
// cyclomatic dependencies
// cyclic dependencies
if ($resolved === null) {
return;
}
@ -536,7 +563,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function where(string | array | self $columns, string | array $operator = null, mixed $values = null, string | array $boolean = 'and') : self
public function where(string | array | self $columns, string | array|null $operator = null, mixed $values = null, string | array $boolean = 'and') : self
{
if (!\is_array($columns)) {
$columns = [$columns];
@ -575,7 +602,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function andWhere(string | array | Where $where, string | array $operator = null, mixed $values = null) : self
public function andWhere(string | array | Where $where, string | array|null $operator = null, mixed $values = null) : self
{
return $this->where($where, $operator, $values, 'and');
}
@ -591,7 +618,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function orWhere(string | array | self $where, string | array $operator = null, mixed $values = null) : self
public function orWhere(string | array | self $where, string | array|null $operator = null, mixed $values = null) : self
{
return $this->where($where, $operator, $values, 'or');
}
@ -814,81 +841,88 @@ class Builder extends BuilderAbstract
*/
public function __toString()
{
return $this->grammar->compileQuery($this);
}
$components = $this->grammar->compileComponents($this);
$queryString = '';
/**
* Find query.
*
* @return void
*
* @since 1.0.0
*/
public function find() : void
{
foreach ($components as $component) {
if ($component !== '') {
$queryString .= $component . ' ';
}
}
return \substr($queryString, 0, -1) . ';';
}
/**
* Count results.
*
* @param string $table Table to count the result set
* @param string $column Table to count the result set
*
* @return Builder
*
* @since 1.0.0
*/
public function count(string $table = '*') : self
public function count(string $column = '*', ?string $as = null) : self
{
/**
* @todo
* Don't do this as a string, create a new object $this->select(new Count($table)).
* The parser should be able to handle this much better
*/
return $this->select('COUNT(' . $table . ')');
return $as === null
? $this->select('COUNT(' . $column . ')')
: $this->selectAs('COUNT(' . $column . ')', $as);
}
/**
* Select minimum.
*
* @return void
* @return Builder
*
* @since 1.0.0
*/
public function min() : void
public function min(string $column = '*', ?string $as = null) : self
{
return $as === null
? $this->select('MIN(' . $column . ')')
: $this->selectAs('MIN(' . $column . ')', $as);
}
/**
* Select maximum.
*
* @return void
* @return Builder
*
* @since 1.0.0
*/
public function max() : void
public function max(string $column = '*', ?string $as = null) : self
{
return $as === null
? $this->select('MAX(' . $column . ')')
: $this->selectAs('MAX(' . $column . ')', $as);
}
/**
* Select sum.
*
* @return void
* @return Builder
*
* @since 1.0.0
*/
public function sum() : void
public function sum(string $column = '*', ?string $as = null) : self
{
return $as === null
? $this->select('SUM(' . $column . ')')
: $this->selectAs('SUM(' . $column . ')', $as);
}
/**
* Select average.
*
* @return void
* @return Builder
*
* @since 1.0.0
*/
public function avg() : void
public function avg(string $column = '*', ?string $as = null) : self
{
return $as === null
? $this->select('AVG(' . $column . ')')
: $this->selectAs('AVG(' . $column . ')', $as);
}
/**
@ -974,7 +1008,7 @@ class Builder extends BuilderAbstract
{
\end($this->values);
$key = \key($this->values);
$key = \key($this->values);
$key ??= 0;
if (\is_array($value)) {
@ -1062,28 +1096,6 @@ class Builder extends BuilderAbstract
return $this;
}
/**
* Increment value.
*
* @return void
*
* @since 1.0.0
*/
public function increment() : void
{
}
/**
* Decrement value.
*
* @return void
*
* @since 1.0.0
*/
public function decrement() : void
{
}
/**
* Join.
*
@ -1095,7 +1107,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function join(string | self $table, string $type = JoinType::JOIN, string $alias = null) : self
public function join(string | self $table, string $type = JoinType::JOIN, ?string $alias = null) : self
{
$this->joins[$alias ?? $table] = ['type' => $type, 'table' => $table, 'alias' => $alias];
@ -1112,7 +1124,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function leftJoin(string | self $table, string $alias = null) : self
public function leftJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::LEFT_JOIN, $alias);
}
@ -1127,7 +1139,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function leftOuterJoin(string | self $table, string $alias = null) : self
public function leftOuterJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::LEFT_OUTER_JOIN, $alias);
}
@ -1142,7 +1154,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function leftInnerJoin(string | self $table, string $alias = null) : self
public function leftInnerJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::LEFT_INNER_JOIN, $alias);
}
@ -1157,7 +1169,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function rightJoin(string | self $table, string $alias = null) : self
public function rightJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::RIGHT_JOIN, $alias);
}
@ -1172,7 +1184,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function rightOuterJoin(string | self $table, string $alias = null) : self
public function rightOuterJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::RIGHT_OUTER_JOIN, $alias);
}
@ -1187,7 +1199,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function rightInnerJoin(string | self $table, string $alias = null) : self
public function rightInnerJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::RIGHT_INNER_JOIN, $alias);
}
@ -1202,7 +1214,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function outerJoin(string | self $table, string $alias = null) : self
public function outerJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::OUTER_JOIN, $alias);
}
@ -1217,7 +1229,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function innerJoin(string | self $table, string $alias = null) : self
public function innerJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::INNER_JOIN, $alias);
}
@ -1232,7 +1244,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function crossJoin(string | self $table, string $alias = null) : self
public function crossJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::CROSS_JOIN, $alias);
}
@ -1247,7 +1259,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function fullJoin(string | self $table, string $alias = null) : self
public function fullJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::FULL_JOIN, $alias);
}
@ -1262,7 +1274,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function fullOuterJoin(string | self $table, string $alias = null) : self
public function fullOuterJoin(string | self $table, ?string $alias = null) : self
{
return $this->join($table, JoinType::FULL_OUTER_JOIN, $alias);
}
@ -1285,7 +1297,7 @@ class Builder extends BuilderAbstract
* @param string|array $columns Columns to join on
* @param null|string|array $operator Comparison operator
* @param null|string|array $values Values to compare with
* @param string|array $boolean Concatonator
* @param string|array $boolean Concatenation
* @param null|string $table Table this belongs to
*
* @return Builder
@ -1294,7 +1306,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function on(string | array $columns, string | array $operator = null, mixed $values = null, string | array $boolean = 'and', string $table = null) : self
public function on(string | array $columns, string | array|null $operator = null, mixed $values = null, string | array $boolean = 'and', ?string $table = null) : self
{
if (!\is_array($columns)) {
$columns = [$columns];
@ -1305,7 +1317,7 @@ class Builder extends BuilderAbstract
$joinCount = \count($this->joins) - 1;
$i = 0;
$table ??= \array_keys($this->joins)[$joinCount];
$table ??= \array_keys($this->joins)[$joinCount];
foreach ($columns as $column) {
if (isset($operator[$i]) && !\in_array(\strtolower($operator[$i]), self::OPERATORS)) {
@ -1336,7 +1348,7 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function orOn(string | array $columns, string | array $operator = null, string | array $values = null) : self
public function orOn(string | array $columns, string | array|null $operator = null, string | array|null $values = null) : self
{
return $this->on($columns, $operator, $values, 'or');
}
@ -1352,34 +1364,21 @@ class Builder extends BuilderAbstract
*
* @since 1.0.0
*/
public function andOn(string | array $columns, string | array $operator = null, string | array $values = null) : self
public function andOn(string | array $columns, string | array|null $operator = null, string | array|null $values = null) : self
{
return $this->on($columns, $operator, $values, 'and');
}
/**
* Merging query.
*
* Merging query in order to remove database query volume
*
* @return Builder
*
* @since 1.0.0
*/
public function merge() : self
{
return clone($this);
}
/**
* {@inheritdoc}
*/
public function execute() : ?\PDOStatement
{
$sth = null;
$sql = '';
try {
$sth = $this->connection->con->prepare($this->toSql());
$sth = $this->connection->con->prepare($sql = $this->toSql());
if ($sth === false) {
return null;
}
@ -1395,7 +1394,7 @@ class Builder extends BuilderAbstract
// @codeCoverageIgnoreStart
\phpOMS\Log\FileLogger::getInstance()->error(
\phpOMS\Log\FileLogger::MSG_FULL, [
'message' => $t->getMessage() . ':' . $this->toSql(),
'message' => $t->getMessage() . ':' . $sql,
'line' => __LINE__,
'file' => self::class,
]
@ -1445,8 +1444,6 @@ class Builder extends BuilderAbstract
{
if (\is_string($column)) {
return $column;
} elseif ($column instanceof Column) {
return $column->getColumn();
} elseif ($column instanceof SerializableInterface) {
return $column->serialize();
} elseif ($column instanceof self) {

View File

@ -1,27 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query;
/**
* Database query builder.
*
* @package phpOMS\DataStorage\Database\Query
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Column extends Builder
{
}

View File

@ -22,6 +22,12 @@ namespace phpOMS\DataStorage\Database\Query;
* @link https://jingga.app
* @since 1.0.0
*/
class From extends Builder
final class ColumnName
{
public string $name = '';
public function __construct(string $name)
{
$this->name = $name;
}
}

View File

@ -0,0 +1,67 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query;
use phpOMS\DataStorage\Database\Connection\ConnectionAbstract;
/**
* Database query builder.
*
* @package phpOMS\DataStorage\Database\Query
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Concat extends Builder
{
public string $delim = '';
public string $as = '';
/**
* Constructor.
*
* @param ConnectionAbstract $connection Database connection
*
* @since 1.0.0
*/
public function __construct(ConnectionAbstract $connection)
{
parent::__construct($connection);
$this->type = QueryType::SELECT;
}
public function columns(string $as, string $delim, ...$columns) : void
{
$this->delim = $delim;
$this->as = $as;
$this->select($columns);
}
/**
* {@inheritdoc}
*/
public function toSql() : string
{
$query = $this->grammar->compileConcat($this, $this->selects);
if (self::$log) {
\phpOMS\Log\FileLogger::getInstance()->debug($query);
}
return $query;
}
}

View File

@ -1,27 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query;
/**
* Database query builder.
*
* @package phpOMS\DataStorage\Database\Query
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Count extends Builder
{
}

View File

@ -1,27 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query;
/**
* Database query builder.
*
* @package phpOMS\DataStorage\Database\Query
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Expression extends Builder
{
}

View File

@ -17,9 +17,7 @@ namespace phpOMS\DataStorage\Database\Query\Grammar;
use phpOMS\DataStorage\Database\BuilderAbstract;
use phpOMS\DataStorage\Database\GrammarAbstract;
use phpOMS\DataStorage\Database\Query\Builder;
use phpOMS\DataStorage\Database\Query\From;
use phpOMS\DataStorage\Database\Query\QueryType;
use phpOMS\DataStorage\Database\Query\Where;
/**
* Database query grammar.
@ -36,7 +34,7 @@ class Grammar extends GrammarAbstract
*
* @throws \InvalidArgumentException
*/
protected function compileComponents(BuilderAbstract $query) : array
public function compileComponents(BuilderAbstract $query) : array
{
/** @var Builder $query */
@ -182,6 +180,21 @@ class Grammar extends GrammarAbstract
return '';
}
/**
* Create concat
*
* @param Concat $query Builder
* @param array $columns Columns
*
* @return string
*
* @since 1.0.0
*/
public function compileConcat(\phpOMS\DataStorage\Database\Query\Concat $query, array $columns) : string
{
return 'CONCAT(' . $this->expressionizeTableColumn($columns) . ') AS ' . $query->as;
}
/**
* Compile select.
*
@ -243,21 +256,21 @@ class Grammar extends GrammarAbstract
* Compile where.
*
* @param Builder $query Builder
* @param array $wheres Where elmenets
* @param bool $first Is first element (usefull for nesting)
* @param array $wheres Where elements
* @param bool $first Is first element (useful for nesting)
*
* @return string
*
* @since 1.0.0
*/
protected function compileWheres(Builder $query, array $wheres, bool $first = true) : string
public function compileWheres(Builder $query, array $wheres, bool $first = true) : string
{
$expression = '';
foreach ($wheres as $where) {
foreach ($where as $element) {
$expression .= $this->compileWhereElement($element, $query, $first);
$first = false;
$first = false;
}
}
@ -273,7 +286,7 @@ class Grammar extends GrammarAbstract
*
* @param array $element Element data
* @param Builder $query Query builder
* @param bool $first Is first element (usefull for nesting)
* @param bool $first Is first element (useful for nesting)
*
* @return string
*
@ -282,58 +295,46 @@ class Grammar extends GrammarAbstract
protected function compileWhereElement(array $element, Builder $query, bool $first = true) : string
{
$expression = '';
$prefix = '';
if (!$first) {
$expression = ' ' . \strtoupper($element['boolean']) . ' ';
$prefix = ' ' . \strtoupper($element['boolean']) . ' ';
}
if (\is_string($element['column'])) {
$expression .= $this->compileSystem($element['column']);
} elseif ($element['column'] instanceof \Closure) {
$expression .= $element['column']();
} elseif ($element['column'] instanceof Where) {
$where = \rtrim($this->compileWhereQuery($element['column']), ';');
$expression .= '(' . (\str_starts_with($where, 'WHERE ') ? \substr($where, 6) : $where) . ')';
} elseif ($element['column'] instanceof Builder) {
$expression .= '(' . \rtrim($element['column']->toSql(), ';') . ')';
} elseif ($element['column'] instanceof \Closure) {
$expression .= $element['column']();
}
if (isset($element['value']) && (!empty($element['value']) || !\is_array($element['value']))) {
// Handle null for IN (...)
// This is not allowed and must be written as (IN (...) OR IS NULL)
$isArray = \is_array($element['value']);
$hasNull = false;
if ($isArray && ($key = \array_search(null, $element['value'], true)) !== false) {
$hasNull = true;
unset($element['value'][$key]);
if (empty($element['value'])) {
$element['operator'] = '=';
$element['value'] = null;
}
}
if (isset($element['value']) && (!empty($element['value']) || !$isArray)) {
$expression .= ' ' . \strtoupper($element['operator']) . ' ' . $this->compileValue($query, $element['value']);
if ($hasNull) {
$expression = '(' . $expression . ' OR ' . $this->compileSystem($element['column']) . ' IS NULL)';
}
} elseif ($element['value'] === null && !($element['column'] instanceof Builder)) {
$operator = $element['operator'] === '=' ? 'IS' : 'IS NOT';
$operator = $element['operator'] === '=' ? 'IS' : 'IS NOT';
$expression .= ' ' . $operator . ' ' . $this->compileValue($query, $element['value']);
}
return $expression;
}
/**
* Compile where query.
*
* @param Where $where Where query
*
* @return string
*
* @since 1.0.0
*/
protected function compileWhereQuery(Where $where) : string
{
return $where->toSql();
}
/**
* Compile from query.
*
* @param From $from Where query
*
* @return string
*
* @since 1.0.0
*/
protected function compileFromQuery(From $from) : string
{
return $from->toSql();
return $prefix . $expression;
}
/**
@ -414,7 +415,7 @@ class Grammar extends GrammarAbstract
foreach ($ons as $on) {
$expression .= $this->compileOnElement($on, $query, $first);
$first = false;
$first = false;
}
if ($expression === '') {
@ -429,7 +430,7 @@ class Grammar extends GrammarAbstract
*
* @param array $element Element data
* @param Builder $query Query builder
* @param bool $first Is first element (usefull for nesting)
* @param bool $first Is first element (useful for nesting)
*
* @return string
*
@ -444,25 +445,27 @@ class Grammar extends GrammarAbstract
}
if (\is_string($element['column'])) {
// handle bug when no table is specified in the where column
// @bug Handle bug when no table is specified in the where column
if (\count($query->from) === 1 && \stripos($element['column'], '.') === false) {
$element['column'] = $query->from[0] . '.' . $element['column'];
}
$expression .= $this->compileSystem($element['column']);
} elseif ($element['column'] instanceof \Closure) {
$expression .= $element['column']();
} elseif ($element['column'] instanceof Builder) {
$expression .= '(' . $element['column']->toSql() . ')';
} elseif ($element['column'] instanceof Where) {
$expression .= '(' . \rtrim($this->compileWhereQuery($element['column']), ';') . ')';
} elseif ($element['column'] instanceof \Closure) {
$expression .= $element['column']();
}
// @todo: on doesn't allow values as value (only table column names). This is bad and needs to be fixed!
// @bug The on part of a join doesn't allow string values because they conflict with column name
// Other data types are possible because they don't conflict with the data type of columns (string)
// Consider to create a ColumnName() class.
// https://github.com/Karaka-Management/phpOMS/issues/369
if (isset($element['value'])) {
$expression .= ' ' . \strtoupper($element['operator']) . ' ' . $this->compileSystem($element['value']);
$expression .= ' ' . \strtoupper($element['operator']) . ' '
. (\is_string($element['value']) ? $this->compileSystem($element['value']) : $element['value']);
} else {
$operator = $element['operator'] === '=' ? 'IS' : 'IS NOT';
$operator = $element['operator'] === '=' ? 'IS' : 'IS NOT';
$expression .= ' ' . $operator . ' ' . $this->compileValue($query, $element['value']);
}

View File

@ -1,27 +0,0 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query\Grammar
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query\Grammar;
/**
* Grammar interface.
*
* @package phpOMS\DataStorage\Database\Query\Grammar
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
interface GrammarInterface
{
}

View File

@ -60,6 +60,10 @@ class MysqlGrammar extends Grammar
$expression = '*';
}
return 'SELECT ' . $expression . ' ' . $this->compileFrom($query, $query->from) . ' ' . $this->compileWheres($query, $query->wheres) . ' ORDER BY \rand() ' . $this->compileLimit($query, $query->limit ?? 1);
return 'SELECT ' . $expression
. ' ' . $this->compileFrom($query, $query->from)
. ' ' . $this->compileWheres($query, $query->wheres)
. ' ORDER BY RAND() '
. $this->compileLimit($query, $query->limit ?? 1);
}
}

View File

@ -62,4 +62,22 @@ class SQLiteGrammar extends Grammar
return 'SELECT ' . $expression . ' ' . $this->compileFrom($query, $query->from) . ' ORDER BY RANDOM() ' . $this->compileLimit($query, $query->limit ?? 1);
}
/**
* Create concat
*
* @param Concat $query Builder
* @param array $columns Columns
*
* @return string
*
* @since 1.0.0
*/
public function compileConcat(\phpOMS\DataStorage\Database\Query\Concat $query, array $columns) : string
{
$sql = $this->expressionizeTableColumn($columns);
$sql = \str_replace(',', ' ||', $sql);
return $sql . ' AS ' . $query->as;
}
}

Some files were not shown because too many files have changed in this diff Show More