Merge branch 'develop'

This commit is contained in:
Dennis Eichhorn 2024-03-15 22:52:10 +00:00
commit cc243cffd7
686 changed files with 18843 additions and 6605 deletions

12
.github/FUNDING.yml vendored
View File

@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # orange_management
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://paypal.me/orangemgmt']

View File

@ -1,37 +0,0 @@
---
name: Dev Bug Report
about: Create a report to help us improve
title: ''
labels: stat_backlog, type_bug
assignees: ''
---
# Bug Description
A clear and concise description of what the bug is.
# How to Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Minimal Code Example
```php
<?php
// your code ...
?>
```
# Expected Behavior
A clear and concise description of what you expected to happen.
# Screenshots
If applicable, add screenshots to help explain your problem.
# Additional Information
Add any other context about the problem here.

View File

@ -1,18 +0,0 @@
---
name: Dev Feature Request
about: Suggest an idea for this project
title: ''
labels: stat_backlog, type_feature
assignees: ''
---
# What is the feature you request
* A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
* A clear and concise description of what you want to happen.
# Alternatives
A clear and concise description of any alternative solutions or features you've considered.
# Additional Information
Add any other context or screenshots about the feature request here.

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 = $this->similarityMatrix;
$this->availabilityMatrix = $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 = $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,141 @@ 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);
}
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
return null;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
}

View File

@ -25,6 +25,37 @@ namespace phpOMS\Algorithm\Clustering;
*
* @todo Implement
*/
final class Birch
final class Birch implements ClusteringInterface
{
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
return null;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
}

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

@ -0,0 +1,65 @@
<?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 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 DivisiveClustering implements ClusteringInterface
{
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
return null;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
}

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?->name] = $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,37 @@ namespace phpOMS\Algorithm\Clustering;
*
* @todo Implement
*/
final class SpectralClustering
final class SpectralClustering implements ClusteringInterface
{
/**
* {@inheritdoc}
*/
public function getCentroids() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function getClusters() : array
{
return [];
}
/**
* {@inheritdoc}
*/
public function cluster(PointInterface $point) : ?PointInterface
{
return null;
}
/**
* {@inheritdoc}
*/
public function getNoise() : array
{
return [];
}
}

View File

@ -1,30 +0,0 @@
<?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 points
*
* @package phpOMS\Algorithm\Clustering
* @license OMS License 2.0
* @link https://jingga.app
* @see ./clustering_overview.png
* @since 1.0.0
*
* @todo Implement
*/
final class Ward
{
}

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

@ -0,0 +1,28 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Scheduling\Dependency
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Scheduling\Dependency;
/**
* Material.
*
* @package phpOMS\Scheduling\Dependency
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Material
{
public int $id = 0;
}

View File

@ -0,0 +1,28 @@
<?php
/**
* Jingga
*
* PHP Version 8.1
*
* @package phpOMS\Scheduling\Dependency
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Scheduling\Dependency;
/**
* Material.
*
* @package phpOMS\Scheduling\Dependency
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Qualification
{
public int $id = 0;
}

View File

@ -24,32 +24,96 @@ namespace phpOMS\Scheduling;
*/
class Job
{
/**
* Id
*
* @var int
* @since 1.0.0
*/
public int $id = 0;
/**
* Time of the execution
*
* @var int
* @since 1.0.0
*/
public int $executionTime = 0;
/**
* Priority.
*
* @var float
* @since 1.0.0
*/
public float $priority = 0.0;
/**
* Value this job generates.
*
* @var float
* @since 1.0.0
*/
public float $value = 0.0;
/**
* Cost of executing this job.
*
* @var float
* @since 1.0.0
*/
public float $cost = 0.0;
/** How many iterations has this job been on hold in the queue */
/**
* How many iterations has this job been on hold in the queue.
*
* @var int
* @since 1.0.0
*/
public int $onhold = 0;
/** How many iterations has this job been in process in the queue */
/**
* How many iterations has this job been in process in the queue.
*
* @var int
* @since 1.0.0
*/
public int $inprocessing = 0;
/**
* What is the deadline for this job?
*
* @param \DateTime
* @since 1.0.0
*/
public \DateTime $deadline;
/**
* Which steps must be taken during the job execution
*
* @var JobStep[]
* @since 1.0.0
*/
public array $steps = [];
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct()
{
$this->deadline = new \DateTime('now');
}
public function getProfit()
/**
* Get the profit of the job
*
* @return float
*
* @since 1.0.0
*/
public function getProfit() : float
{
return $this->value - $this->cost;
}

View File

@ -24,8 +24,24 @@ namespace phpOMS\Scheduling;
*/
final class ScheduleQueue
{
/**
* Queue
*
* @var Job[]
* @since 1.0.0
*/
public array $queue = [];
/**
* Get element from queue
*
* @param int $size Amount of elements to return
* @param int $type Priority type to use for return
*
* @return Job[]
*
* @since 1.0.0
*/
public function get(int $size = 1, int $type = PriorityMode::FIFO) : array
{
$jobs = [];
@ -103,11 +119,33 @@ final class ScheduleQueue
return $jobs;
}
/**
* Insert new element into queue
*
* @param int $id Element id
* @param Job $job Element to add
*
* @return void
*
* @since 1.0.0
*/
public function insert(int $id, Job $job) : void
{
$this->queue[$id] = $job;
}
/**
* Pop elements from the queue.
*
* This also removes the elements from the queue
*
* @param int $size Amount of elements to return
* @param int $type Priority type to use for return
*
* @return Job[]
*
* @since 1.0.0
*/
public function pop(int $size = 1, int $type = PriorityMode::FIFO) : array
{
$jobs = $this->get($size, $type);
@ -118,6 +156,15 @@ final class ScheduleQueue
return $jobs;
}
/**
* Increases the hold counter of an element
*
* @param int $id Id of the element (0 = all elements)
*
* @return void
*
* @since 1.0.0
*/
public function bumpHold(int $id = 0) : void
{
if ($id === 0) {
@ -129,6 +176,16 @@ final class ScheduleQueue
}
}
/**
* Change the priority of an element
*
* @param int $id Id of the element (0 = all elements)
* @param float $priority Priority to increase by
*
* @return void
*
* @since 1.0.0
*/
public function adjustPriority(int $id = 0, float $priority = 0.1) : void
{
if ($id === 0) {
@ -140,7 +197,16 @@ final class ScheduleQueue
}
}
public function remove(string $id) : void
/**
* Remove an element from the queue
*
* @param int $id Id of the element
*
* @return void
*
* @since 1.0.0
*/
public function remove(int $id) : void
{
unset($this->queue[$id]);
}

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

@ -22,25 +22,92 @@ use phpOMS\Math\Stochastic\Distribution\NormalDistribution;
* @package phpOMS\Algorithm\Rating
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
* @see https://www.moserware.com/assets/computing-your-skill/The%20Math%20Behind%20TrueSkill.pdf
* @since 1.0.0
*
* @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;
/**
* Constructor.
*
* @param null|float $mu Mu
* @param null|float $sigma Sigma
* @param null|float $beta Beta
* @param null|float $tau Tau
* @param null|float $drawProbability Draw probability
*
* @since 1.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;
}
/**
* Calculate win probability
*
* @param array $team1 Team 1
* @param array $team2 Team 2
* @param float $drawMargin Draw margin
*
* @return float
*
* @since 1.0.0
*/
public function winProbability(array $team1, array $team2, float $drawMargin = 0.0) : float
{
$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
@ -159,22 +226,37 @@ class TrueSkill
/ (NormalDistribution::getCdf($epsilon - $tAbs, 0.0, 1.0) - NormalDistribution::getCdf(-$epsilon - $tAbs, 0.0, 1.0));
}
/**
*
*/
private function buildRatingLayer() : void
{
}
/**
*
*/
private function buildPerformanceLayer() : void
{
}
/**
*
*/
private function buildTeamPerformanceLayer() : void
{
}
/**
*
*/
private function buildTruncLayer() : void
{
}
/**
*
*/
private function factorGraphBuilders()
{
// Rating layer
@ -193,6 +275,9 @@ class TrueSkill
];
}
/**
*
*/
public function rating() : void
{
// Start values

View File

@ -4,7 +4,7 @@
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query
* @package phpOMS\Algorithm\Rating
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
@ -12,16 +12,17 @@
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query;
namespace phpOMS\Algorithm\Rating;
/**
* Database query builder.
* Elo rating calculation using Elo rating
*
* @package phpOMS\DataStorage\Database\Query
* @package phpOMS\Algorithm\Rating
* @license OMS License 2.0
* @link https://jingga.app
* @see https://en.wikipedia.org/wiki/Elo_rating_system
* @since 1.0.0
*/
class Expression extends Builder
final class TrueSkillFactoryGraph
{
}

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\DHLInternationalShipping();
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

@ -195,6 +195,14 @@ class ApplicationAbstract
*/
protected EventManager $eventManager;
/**
* Application version.
*
* @var string
* @since 1.0.0
*/
public string $version = '1.0.0';
/**
* Set values
*

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

@ -37,9 +37,15 @@ final class BayesianPersonalizedRanking
private array $itemFactors = [];
// 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.
/**
* Constructor.
*
* @param int $numFactors Determines the dimensionality of the latent factor space.
* @param float $learningRate Controls the step size for updating the latent factors during optimization.
* @param float $regularization Prevents over-fitting by adding a penalty for large parameter values.
*
* @since 1.0.0
*/
public function __construct(int $numFactors, float $learningRate, float $regularization)
{
$this->numFactors = $numFactors;
@ -47,7 +53,14 @@ final class BayesianPersonalizedRanking
$this->regularization = $regularization;
}
private function generateRandomFactors()
/**
* Calculate random factors
*
* @return array
*
* @since 1.0.0
*/
private function generateRandomFactors() : array
{
$factors = [];
for ($i = 0; $i < $this->numFactors; ++$i) {
@ -57,6 +70,9 @@ final class BayesianPersonalizedRanking
return $factors;
}
/**
* @todo implement
*/
public function predict($userId, $itemId) {
$userFactor = $this->userFactors[$userId];
$itemFactor = $this->itemFactors[$itemId];
@ -69,6 +85,9 @@ final class BayesianPersonalizedRanking
return $score;
}
/**
* @todo implement
*/
public function updateFactors($userId, $posItemId, $negItemId) : void
{
if (!isset($this->userFactors[$userId])) {

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,34 @@
<?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
{
/**
* 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,46 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at spl1nes.com@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View File

@ -1,82 +0,0 @@
# 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).
## Code of conduct
Every organization member and contributor to the organization must follow the [code of conduct](../Policies & Guidelines/Code of conduct.md).
## Code changes
### Topics / Tasks / Todos
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.
Open tasks can be found in the project overview: [PROJECT.md](https://github.com/Karaka-Management/Organization-Guide/blob/master/Project/PROJECT.md)
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.
### Code style
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.
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.
Automated checks which are run during the review process:
```sh
php ./vendor/bin/phpcs --severity=1 ./ --standard="Build/Config/phpcs.xml"
npx eslint ./ -c ./Build/Config/.eslintrc.json
```
### 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.
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.
Automated tests which are run during the review process:
```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 ./
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.
### 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.
### 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.
### Release flow
Code changes must be performed in a new branch. A new branch can be created with:
```sh
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
* `bug-*` for bug fixes
* `security-*` for security related fixes/improvements
* `general-*` for general improvements (i.e. code documentation improvements, code style improvements)
The senior developer who performs the code review merges the change request into the `develop` branch upon approval.

View File

@ -45,19 +45,19 @@ 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;
/**
* Set option by key.
*
* @param array<int, array{id?:?int, name?:?string, content:string, module?:?string, group?:?int, account?:?int}> $options Column values for filtering
* @param bool $store Save this Setting immediately to database
* @param array<int, mixed> $options Column values for filtering
* @param bool $store Save this Setting immediately to database
*
* @return void
*
@ -68,7 +68,7 @@ interface SettingsInterface extends OptionsInterface
/**
* Save options.
*
* @param array<int, array{id?:?int, name?:?string, content:string, module?:?string, group?:?int, account?:?int}> $options Options to save
* @param array<int, mixed> $options Options to save
*
* @return void
*

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;
@ -286,7 +244,7 @@ abstract class GrammarAbstract
}
return $values . $this->compileValue($query, $value[$count]) . ')';
} elseif ($value instanceof \DateTime) {
} elseif ($value instanceof \DateTime || $value instanceof \DateTimeImmutable) {
return $query->quote($value->format($this->datetimeFormat));
} elseif ($value === null) {
return 'NULL';
@ -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,30 @@ abstract class DataMapperAbstract
$this->db = $db;
}
/**
* Column name of the index
*
* @var string
* @since 1.0.0
*/
protected string $indexedBy = '';
/**
* Set column name where the id is defined
*
* @param string $index Column name of the index
*
* @return self
*
* @since 1.0.0
*/
public function indexedBy(string $index) : self
{
$this->indexedBy = $index;
return $this;
}
/**
* Define a query which is merged with the internal query generation.
*
@ -152,7 +191,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,30 @@ class DataMapperFactory
return static::FACTORY::createWith($data);
}
/**
* Mapper uses a factory
*
* @return bool
*
* @since 1.0.0
*/
public static function hasFactory() : bool
{
return !empty(static::FACTORY);
}
/**
* Get base model class name
*
* @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 +509,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 +535,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 +581,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 +589,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 +648,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 +700,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 +743,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,13 +162,26 @@ 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();
if ($sth === false) {
throw new \Exception();
}
$deadlock = 0;
do {
$repeat = false;
try {
++$deadlock;
$sth->execute();
} catch (\Throwable $t) {
if ($deadlock > 3 || $t->errorInfo[1] !== 1213) {
throw $t;
}
\usleep(10000);
$repeat = true;
}
} while ($repeat);
} catch (\Throwable $t) {
// @codeCoverageIgnoreStart
\phpOMS\Log\FileLogger::getInstance()->error(
@ -196,11 +210,48 @@ final class UpdateMapper extends DataMapperAbstract
/** @var class-string<DataMapperFactory> $mapper */
$mapper = $this->mapper::BELONGS_TO[$propertyName]['mapper'];
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName);
$relMapper->depth = $this->depth + 1;
if (isset($this->with[$propertyName])) {
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName);
$relMapper->depth = $this->depth + 1;
return $relMapper->execute($obj);
$id = $relMapper->execute($obj);
if (!isset($this->mapper::OWNS_ONE[$propertyName]['by'])) {
return $id;
}
if ($this->mapper::OWNS_ONE[$propertyName]['private'] ?? false) {
$refClass = new \ReflectionClass($obj);
$refProp = $refClass->getProperty($this->mapper::OWNS_ONE[$propertyName]['by']);
$value = $refProp->getValue($obj);
} else {
$value = $obj->{$this->mapper::OWNS_ONE[$propertyName]['by']};
}
return $value;
}
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']);
$obj = $refProp->getValue($obj);
} else {
$obj = $obj->{$this->mapper::BELONGS_TO[$propertyName]['by']};
}
if (!\is_object($obj)) {
return $obj;
}
}
$id = $mapper::getObjectId($obj);
return empty($id) && $mapper::isNullModel($obj)
? null
: $id;
}
/**
@ -218,11 +269,48 @@ final class UpdateMapper extends DataMapperAbstract
/** @var class-string<DataMapperFactory> $mapper */
$mapper = $this->mapper::OWNS_ONE[$propertyName]['mapper'];
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName);
$relMapper->depth = $this->depth + 1;
if (isset($this->with[$propertyName])) {
/** @var self $relMapper */
$relMapper = $this->createRelationMapper($mapper::update(db: $this->db), $propertyName);
$relMapper->depth = $this->depth + 1;
return $relMapper->execute($obj);
$id = $relMapper->execute($obj);
if (!isset($this->mapper::OWNS_ONE[$propertyName]['by'])) {
return $id;
}
if ($this->mapper::OWNS_ONE[$propertyName]['private'] ?? false) {
$refClass = new \ReflectionClass($obj);
$refProp = $refClass->getProperty($this->mapper::OWNS_ONE[$propertyName]['by']);
$value = $refProp->getValue($obj);
} else {
$value = $obj->{$this->mapper::OWNS_ONE[$propertyName]['by']};
}
return $value;
}
if (isset($this->mapper::OWNS_ONE[$propertyName]['by'])) {
// has by (obj is stored as a different model e.g. model = profile but reference/db is account)
if ($this->mapper::OWNS_ONE[$propertyName]['private'] ?? false) {
$refClass = new \ReflectionClass($obj);
$refProp = $refClass->getProperty($this->mapper::OWNS_ONE[$propertyName]['by']);
$obj = $refProp->getValue($obj);
} else {
$obj = $obj->{$this->mapper::OWNS_ONE[$propertyName]['by']};
}
if (!\is_object($obj)) {
return $obj;
}
}
$id = $mapper::getObjectId($obj);
return empty($id) && $mapper::isNullModel($obj)
? null
: $id;
}
/**
@ -238,7 +326,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 +348,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 +432,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 +461,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);
@ -162,7 +159,25 @@ final class WriteMapper extends DataMapperAbstract
}
$sth = $this->db->con->prepare($query->toSql());
$sth->execute();
if ($sth === false) {
throw new \Exception();
}
$deadlock = 0;
do {
$repeat = false;
try {
++$deadlock;
$sth->execute();
} catch (\Throwable $t) {
if ($deadlock > 3 || $t->errorInfo[1] !== 1213) {
throw $t;
}
\usleep(10000);
$repeat = true;
}
} while ($repeat);
$objId = empty($id = $this->mapper::getObjectId($obj)) ? $this->db->con->lastInsertId() : $id;
\settype($objId, $this->mapper::COLUMNS[$this->mapper::PRIMARYFIELD]['type']);
@ -195,19 +210,35 @@ final class WriteMapper extends DataMapperAbstract
*/
private function createOwnsOne(string $propertyName, object $obj) : mixed
{
if (!\is_object($obj)) {
return $obj;
// @question This code prevents us from EVER creating an object with a 'by' reference since we always assume
// that it already exists -> only return the custom reference id
// See bug below.
// @todo We might also have to handle 'column'
if (isset($this->mapper::OWNS_ONE[$propertyName]['by'])) {
// has by (obj is stored as a different model e.g. model = profile but reference/db is account)
if ($this->mapper::OWNS_ONE[$propertyName]['private'] ?? false) {
$refClass = new \ReflectionClass($obj);
$refProp = $refClass->getProperty($this->mapper::OWNS_ONE[$propertyName]['by']);
$obj = $refProp->getValue($obj);
} else {
$obj = $obj->{$this->mapper::OWNS_ONE[$propertyName]['by']};
}
if (!\is_object($obj)) {
return $obj;
}
}
/** @var class-string<DataMapperFactory> $mapper */
$mapper = $this->mapper::OWNS_ONE[$propertyName]['mapper'];
$primaryKey = $mapper::getObjectId($obj);
if (empty($primaryKey)) {
return $mapper::create(db: $this->db)->execute($obj);
}
return $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;
}
/**
@ -222,16 +253,13 @@ final class WriteMapper extends DataMapperAbstract
*/
private function createBelongsTo(string $propertyName, object $obj) : mixed
{
if (!\is_object($obj)) {
return $obj;
}
$mapper = '';
$primaryKey = 0;
// @question This code prevents us from EVER creating an object with a 'by' reference since we always assume
// that it already exists -> only return the custom reference id
// See bug below.
// @todo We might also have to handle 'column'
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']);
@ -239,14 +267,21 @@ final class WriteMapper extends DataMapperAbstract
} else {
$obj = $obj->{$this->mapper::BELONGS_TO[$propertyName]['by']};
}
if (!\is_object($obj)) {
return $obj;
}
}
/** @var class-string<DataMapperFactory> $mapper */
$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 +297,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 +309,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 +319,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 +335,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 +367,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 +382,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 +411,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'])
@ -398,9 +435,25 @@ final class WriteMapper extends DataMapperAbstract
}
$sth = $this->db->con->prepare($relQuery->toSql());
if ($sth !== false) {
$sth->execute();
if ($sth === false) {
throw new \Exception();
}
$deadlock = 0;
do {
$repeat = false;
try {
++$deadlock;
$sth->execute();
} catch (\Throwable $t) {
if ($deadlock > 3 || $t->errorInfo[1] !== 1213) {
throw $t;
}
\usleep(10000);
$repeat = true;
}
} while ($repeat);
} catch (\Throwable $t) {
// @codeCoverageIgnoreStart
\phpOMS\Log\FileLogger::getInstance()->error(

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);
}
@ -1284,8 +1296,8 @@ 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 mixed $values Values to compare with
* @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) {

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