diff --git a/php/src/A.php b/php/src/A.php index 84ca718..ef96c45 100644 --- a/php/src/A.php +++ b/php/src/A.php @@ -18,7 +18,7 @@ class A { if (is_array($array)) return true; if ($array instanceof IArrayWrapper) $array = $array->wrappedArray(); if ($array === null || $array === false) $array = []; - elseif ($array instanceof Traversable) $array = iterator_to_array($array); + elseif ($array instanceof Traversable) $array = cl::all($array); else $array = [$array]; return false; } @@ -28,12 +28,189 @@ class A { * $array n'a pas été modifié (s'il était déjà un array ou s'il valait null). */ static final function ensure_narray(&$array): bool { - if ($array === null || is_array($array)) return true; if ($array instanceof IArrayWrapper) $array = $array->wrappedArray(); + if ($array === null || is_array($array)) return true; if ($array === false) $array = []; - elseif ($array instanceof Traversable) $array = iterator_to_array($array); + elseif ($array instanceof Traversable) $array = cl::all($array); else $array = [$array]; return false; } + /** + * s'assurer que $array est un tableau de $size éléments, en complétant avec + * des occurrences de $default si nécessaire + * + * @return bool true si le tableau a été modifié, false sinon + */ + static final function ensure_size(?array &$array, int $size, $default=null): bool { + $modified = false; + if ($array === null) { + $array = []; + $modified = true; + } + if ($size < 0) return $modified; + $count = count($array); + if ($count == $size) return $modified; + if ($count < $size) { + # agrandir le tableau + while ($count++ < $size) { + $array[] = $default; + } + return true; + } + # rétrécir le tableau + $tmparray = []; + foreach ($array as $key => $value) { + if ($size-- == 0) break; + $tmparray[$key] = $value; + } + $array = $tmparray; + return true; + } + + static function merge(&$dest, ...$merges): void { + self::ensure_narray($dest); + $dest = cl::merge($dest, ...$merges); + } + + static function merge2(&$dest, ...$merges): void { + self::ensure_narray($dest); + $dest = cl::merge2($dest, ...$merges); + } + + static final function select(&$dest, ?array $mappings, bool $inverse=false): void { + self::ensure_narray($dest); + $dest = cl::select($dest, $mappings, $inverse); + } + + static final function selectm(&$dest, ?array $mappings, ?array $merge=null): void { + self::ensure_narray($dest); + $dest = cl::selectm($dest, $mappings, $merge); + } + + static final function mselect(&$dest, ?array $merge, ?array $mappings): void { + self::ensure_narray($dest); + $dest = cl::mselect($dest, $merge, $mappings); + } + + static final function pselect(&$dest, ?array $pkeys): void { + self::ensure_narray($dest); + $dest = cl::pselect($dest, $pkeys); + } + + static final function pselectm(&$dest, ?array $pkeys, ?array $merge=null): void { + self::ensure_narray($dest); + $dest = cl::pselectm($dest, $pkeys, $merge); + } + + static final function mpselect(&$dest, ?array $merge, ?array $pkeys): void { + self::ensure_narray($dest); + $dest = cl::mpselect($dest, $merge, $pkeys); + } + + static final function set_nn(&$dest, $key, $value) { + self::ensure_narray($dest); + if ($value !== null) { + if ($key === null) $dest[] = $value; + else $dest[$key] = $value; + } + return $value; + } + + static final function append_nn(&$dest, $value) { + return self::set_nn($dest, null, $value); + } + + static final function set_nz(&$dest, $key, $value) { + self::ensure_narray($dest); + if ($value !== null && $value !== false) { + if ($key === null) $dest[] = $value; + else $dest[$key] = $value; + } + return $value; + } + + static final function append_nz(&$dest, $value) { + self::ensure_narray($dest); + return self::set_nz($dest, null, $value); + } + + static final function prepend_nn(&$dest, $value) { + self::ensure_narray($dest); + if ($value !== null) { + if ($dest === null) $dest = []; + array_unshift($dest, $value); + } + return $value; + } + + static final function prepend_nz(&$dest, $value) { + self::ensure_narray($dest); + if ($value !== null && $value !== false) { + if ($dest === null) $dest = []; + array_unshift($dest, $value); + } + return $value; + } + + static final function replace_nx(&$dest, $key, $value) { + self::ensure_narray($dest); + if ($dest !== null && !array_key_exists($key, $dest)) { + return $dest[$key] = $value; + } else { + return $dest[$key] ?? null; + } + } + + static final function replace_n(&$dest, $key, $value) { + self::ensure_narray($dest); + $pvalue = $dest[$key] ?? null; + if ($pvalue === null) $dest[$key] = $value; + return $pvalue; + } + + static final function replace_z(&$dest, $key, $value) { + self::ensure_narray($dest); + $pvalue = $dest[$key] ?? null; + if ($pvalue === null || $pvalue === false) $dest[$key] = $value; + return $pvalue; + } + + static final function pop(&$dest, $key, $default=null) { + if ($dest === null) return $default; + self::ensure_narray($dest); + if ($key === null) return array_pop($dest); + $value = $dest[$key] ?? $default; + unset($dest[$key]); + return $value; + } + + static final function popx(&$dest, ?array $keys): array { + $values = []; + if ($dest === null) return $values; + self::ensure_narray($dest); + if ($keys === null) return $values; + foreach ($keys as $key) { + $values[$key] = self::pop($dest, $key); + } + return $values; + } + + static final function filter_if(&$dest, callable $cond): void { + self::ensure_narray($dest); + $dest = cl::filter_if($dest, $cond); + } + + static final function filter_z($dest): void { self::filter_if($dest, [cv::class, "z"]);} + static final function filter_nz($dest): void { self::filter_if($dest, [cv::class, "nz"]);} + static final function filter_n($dest): void { self::filter_if($dest, [cv::class, "n"]);} + static final function filter_nn($dest): void { self::filter_if($dest, [cv::class, "nn"]);} + static final function filter_t($dest): void { self::filter_if($dest, [cv::class, "t"]);} + static final function filter_f($dest): void { self::filter_if($dest, [cv::class, "f"]);} + static final function filter_pt($dest): void { self::filter_if($dest, [cv::class, "pt"]);} + static final function filter_pf($dest): void { self::filter_if($dest, [cv::class, "pf"]);} + static final function filter_equals($dest, $value): void { self::filter_if($dest, cv::equals($value)); } + static final function filter_not_equals($dest, $value): void { self::filter_if($dest, cv::not_equals($value)); } + static final function filter_same($dest, $value): void { self::filter_if($dest, cv::same($value)); } + static final function filter_not_same($dest, $value): void { self::filter_if($dest, cv::not_same($value)); } } diff --git a/php/src/ExitException.php b/php/src/ExitError.php similarity index 56% rename from php/src/ExitException.php rename to php/src/ExitError.php index 5f5233c..b08cdd6 100644 --- a/php/src/ExitException.php +++ b/php/src/ExitError.php @@ -1,22 +1,31 @@ userMessage = $userMessage; } function isError(): bool { return $this->getCode() !== 0; } + /** @var ?string */ + protected $userMessage; + function haveMessage(): bool { - return $this->getUserMessage() !== null; + return $this->userMessage !== null; + } + + function getUserMessage(): ?string { + return $this->userMessage; } } diff --git a/php/src/file/app/LockFile.php b/php/src/app/LockFile.php similarity index 94% rename from php/src/file/app/LockFile.php rename to php/src/app/LockFile.php index cc11b49..d32bd2d 100644 --- a/php/src/file/app/LockFile.php +++ b/php/src/app/LockFile.php @@ -1,5 +1,5 @@ read(); if ($data["locked"]) { msg::warning("$data[name]: possède le verrou depuis $data[date_lock] -- $data[title]"); + return true; } + return false; } function lock(?array &$data=null): bool { diff --git a/php/src/app/RunFile.php b/php/src/app/RunFile.php new file mode 100644 index 0000000..12460a4 --- /dev/null +++ b/php/src/app/RunFile.php @@ -0,0 +1,354 @@ +name = $name ?? static::NAME; + $this->file = new SharedFile($file); + $this->logfile = $logfile; + } + + protected ?string $name; + + protected SharedFile $file; + + protected ?string $logfile; + + function getLogfile(): ?string { + return $this->logfile; + } + + protected static function merge(array $data, array $merge): array { + return cl::merge($data, [ + "serial" => $data["serial"] + 1, + ], $merge); + } + + protected function initData(bool $forStart=true): array { + if ($forStart) { + $pid = posix_getpid(); + $dateStart = new DateTime(); + } else { + $pid = $dateStart = null; + } + return [ + "name" => $this->name, + "id" => bin2hex(random_bytes(16)), + "pg_pid" => null, + "pid" => $pid, + "serial" => 0, + # lock + "locked" => false, + "date_lock" => null, + "date_release" => null, + # run + "logfile" => $this->logfile, + "date_start" => $dateStart, + "date_stop" => null, + "exitcode" => null, + "is_done" => null, + # action + "action" => null, + "action_date_start" => null, + "action_current_step" => null, + "action_max_step" => null, + "action_date_step" => null, + ]; + } + + function read(): array { + $data = $this->file->unserialize(); + if (!is_array($data)) $data = $this->initData(false); + return $data; + } + + protected function willWrite(): array { + $file = $this->file; + $file->lockWrite(); + $data = $file->unserialize(null, false, true); + if (!is_array($data)) { + $data = $this->initData(false); + $file->ftruncate(); + $file->serialize($data, false, true); + } + return [$file, $data]; + } + + protected function serialize(SharedFile $file, array $data, ?array $merge=null): void { + $file->ftruncate(); + $file->serialize(self::merge($data, $merge), true, true); + } + + protected function update(callable $func): void { + /** @var SharedFile$file */ + [$file, $data] = $this->willWrite(); + $merge = call_user_func($func, $data); + if ($merge !== null && $merge !== false) { + $this->serialize($file, $data, $merge); + } else { + $file->cancelWrite(); + } + } + + function haveWorked(int $serial, ?int &$currentSerial=null, ?array $data=null): bool { + $data ??= $this->read(); + $currentSerial = $data["serial"]; + return $serial !== $currentSerial; + } + + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # verrouillage par défaut + + function isLocked(?array &$data=null): bool { + $data = $this->read(); + return $data["locked"]; + } + + function warnIfLocked(?array $data=null): bool { + $data ??= $this->read(); + if ($data["locked"]) { + msg::warning("$data[name]: possède le verrou depuis $data[date_lock]"); + return true; + } + return false; + } + + function lock(): bool { + $this->update(function ($data) use (&$locked) { + if ($data["locked"]) { + $locked = false; + return null; + } else { + $locked = true; + return [ + "locked" => true, + "date_lock" => new DateTime(), + "date_release" => null, + ]; + } + }); + return $locked; + } + + function release(): void { + $this->update(function ($data) { + return [ + "locked" => false, + "date_release" => new DateTime(), + ]; + }); + } + + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # cycle de vie de l'application + + /** + * indiquer que l'application démarre. l'état est entièrement réinitialisé, + * sauf le PID du leader qui est laissé en l'état + */ + function wfStart(): void { + $this->update(function (array $data) { + return cl::merge($this->initData(), [ + "pg_pid" => $data["pg_pid"], + ]); + }); + } + + /** tester si l'application a déjà été démarrée au moins une fois */ + function wasStarted(?array $data=null): bool { + $data ??= $this->read(); + return $data["date_start"] !== null; + } + + /** tester si l'application est démarrée et non arrêtée */ + function isStarted(?array $data=null): bool { + $data ??= $this->read(); + return $data["date_start"] !== null && $data["date_stop"] === null; + } + + /** + * vérifier si l'application marquée comme démarrée tourne réellement + */ + function isRunning(?array $data=null): bool { + $data ??= $this->read(); + if ($data["date_start"] === null) return false; + if ($data["date_stop"] !== null) return false; + if (!posix_kill($data["pid"], 0)) { + switch (posix_get_last_error()) { + case 1: #PCNTL_EPERM: + # process auquel on n'a pas accès?! est-ce un autre process qui a + # réutilisé le PID? + return false; + case 3: #PCNTL_ESRCH: + # process inexistant + return false; + case 22: #PCNTL_EINVAL: + # ne devrait pas se produire + return false; + } + } + # process existant auquel on a accès + return true; + } + + /** indiquer que l'application s'arrête */ + function wfStop(): void { + $this->update(function (array $data) { + return ["date_stop" => new DateTime()]; + }); + } + + /** tester si l'application est déjà été stoppée au moins une fois */ + function wasStopped(?array $data=null): bool { + $data ??= $this->read(); + return $data["date_stop"] !== null; + } + + /** tester si l'application a été démarrée puis arrêtée */ + function isStopped(?array $data=null): bool { + $data ??= $this->read(); + return $data["date_start"] !== null && $data["date_stop"] !== null; + } + + /** après l'arrêt de l'application, mettre à jour le code de retour */ + function wfStopped(int $exitcode): void { + $this->update(function (array $data) use ($exitcode) { + return [ + "pg_pid" => null, + "date_stop" => $data["date_stop"] ?? new DateTime(), + "exitcode" => $exitcode, + ]; + }); + } + + /** + * comme {@link self::isStopped()} mais ne renvoie true qu'une seule fois si + * $updateDone==true + */ + function isDone(?array &$data=null, bool $updateDone=true): bool { + $done = false; + $this->update(function (array $ldata) use (&$done, &$data, $updateDone) { + $data = $ldata; + if ($data["date_start"] === null || $data["date_stop"] === null || $data["is_done"]) { + return false; + } + $done = true; + if ($updateDone) return ["is_done" => $done]; + else return null; + }); + return $done; + } + + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # gestion des actions + + /** indiquer le début d'une action */ + function action(?string $title, ?int $maxSteps=null): void { + $this->update(function (array $data) use ($title, $maxSteps) { + return [ + "action" => $title, + "action_date_start" => new DateTime(), + "action_max_step" => $maxSteps, + "action_current_step" => 0, + ]; + }); + } + + /** indiquer qu'une étape est franchie dans l'action en cours */ + function step(int $nbSteps=1): void { + $this->update(function (array $data) use ($nbSteps) { + return [ + "action_date_step" => new DateTime(), + "action_current_step" => $data["action_current_step"] + $nbSteps, + ]; + }); + } + + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Divers + + function getLockFile(?string $name=null, ?string $title=null): LockFile { + $ext = self::LOCK_EXT; + if ($name !== null) $ext = ".$name$ext"; + $file = path::ensure_ext($this->file->getFile(), $ext, self::RUN_EXT); + $name = str::join("/", [$this->name, $name]); + return new LockFile($file, $name, $title); + } + + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Gestionnaire de tâches (tm_*) + + /** démarrer un groupe de process dont le process courant est le leader */ + function tm_startPg(): void { + $this->update(function (array $data) { + posix_setsid(); + return [ + "pg_pid" => posix_getpid(), + ]; + }); + } + + /** + * vérifier si on est dans le cas où la tâche devrait tourner mais en réalité + * ce n'est pas le cas + */ + function tm_isUndead(?int $pid=null): bool { + $data = $this->read(); + if ($data["date_start"] === null) return false; + if ($data["date_stop"] !== null) return false; + $pid ??= $data["pid"]; + if (!posix_kill($pid, 0)) { + switch (posix_get_last_error()) { + case 1: #PCNTL_EPERM: + # process auquel on n'a pas accès?! est-ce un autre process qui a + # réutilisé le PID? + return false; + case 3: #PCNTL_ESRCH: + # process inexistant + return true; + case 22: #PCNTL_EINVAL: + # ne devrait pas se produire + return false; + } + } + # process existant auquel on a accès + return false; + } + + function tm_isReapable(): bool { + $data = $this->read(); + return $data["date_stop"] !== null && $data["exitcode"] === null; + } + + /** marquer la tâche comme terminée */ + function tm_reap(?int $pid=null): void { + $data = $this->read(); + $pid ??= $data["pid"]; + pcntl_waitpid($pid, $status); + $exitcode = pcntl_wifexited($status)? pcntl_wexitstatus($status): 127; + $this->update(function (array $data) use ($exitcode) { + return [ + "pg_pid" => null, + "date_stop" => $data["date_stop"] ?? new DateTime(), + "exitcode" => $data["exitcode"] ?? $exitcode, + ]; + }); + } +} diff --git a/php/src/app/launcher.php b/php/src/app/launcher.php new file mode 100644 index 0000000..8ec5216 --- /dev/null +++ b/php/src/app/launcher.php @@ -0,0 +1,136 @@ + $value] devient ["--my-arg", "$value"] + * - ["myOpt" => true] devient ["--my-opt"] + * - ["myOpt" => false] est momis + * - les valeurs séquentielles sont prises telles quelles + */ + static function verifix_args(array $args): array { + if (!cl::is_list($args)) { + $fixedArgs = []; + $index = 0; + foreach ($args as $arg => $value) { + if ($arg === $index) { + $index++; + $fixedArgs[] = $value; + continue; + } elseif ($value === false) { + continue; + } + $arg = str::us2camel($arg); + $arg = str::camel2us($arg, false, "-"); + $arg = str_replace("_", "-", $arg); + $fixedArgs[] = "--$arg"; + if ($value !== true) $fixedArgs[] = "$value"; + } + $args = $fixedArgs; + } + # corriger le chemin de l'application pour qu'il soit absolu et normalisé + $args[0] = path::abspath($args[0]); + return $args; + } + + static function launch(string $appClass, array $args): int { + $app = app::get(); + $vendorBindir = $app->getVendorbindir(); + $launch_php = "$vendorBindir/_launch.php"; + if (!file_exists($launch_php)) { + $launch_php = __DIR__."/../../lib/_launch.php"; + } + $tmpfile = new TmpfileWriter(); + $tmpfile->keep()->serialize($app->getParams()); + + $args = self::verifix_args($args); + $cmd = new Cmd([ + $launch_php, + "--internal-use", $tmpfile->getFile(), + $appClass, "--", ...$args, + ]); + $cmd->addRedir("both", "/tmp/nulib_app_launcher-launch.log"); + $cmd->passthru($exitcode); + + # attendre un peu que la commande aie le temps de s'initialiser + sleep(1); + + $tmpfile->close(); + return $exitcode; + } + + static function _start(array $args, Runfile $runfile): bool { + if ($runfile->warnIfLocked()) return false; + $pid = pcntl_fork(); + if ($pid == -1) { + # parent, impossible de forker + throw new StateException("unable to fork"); + } elseif ($pid) { + # parent, fork ok + return true; + } else { + ## child, fork ok + # Créer un groupe de process, pour pouvoir tuer tous les enfants en même temps + $runfile->tm_startPg(); + $logfile = $runfile->getLogfile() ?? "/tmp/nulib_app_launcher-_start.log"; + $pid = posix_getpid(); + $exitcode = -776; + try { + # puis lancer la commande + $cmd = new Cmd($args); + $cmd->addSource("/g/init.env"); + $cmd->addRedir("both", $logfile, true); + msg::debug("$pid: launching\n".$cmd->getCmd()); + $cmd->fork_exec($exitcode); + msg::debug("$pid: exitcode=$exitcode"); + return true; + } finally { + $runfile->wfStopped($exitcode); + } + } + } + + static function _stop(Runfile $runfile): void { + $data = $runfile->read(); + $pid = $data["pg_pid"]; + if ($pid === null) { + msg::warning("$data[name]: groupe de process inconnu"); + return; + } + msg::action("kill $pid"); + if (!posix_kill(-$pid, SIGKILL)) { + switch (posix_get_last_error()) { + case PCNTL_ESRCH: + msg::afailure("process inexistant"); + break; + case PCNTL_EPERM: + msg::afailure("process non accessible"); + break; + case PCNTL_EINVAL: + msg::afailure("signal invalide"); + break; + } + return; + } + $timeout = 10; + while ($runfile->tm_isUndead($pid)) { + sleep(1); + if (--$timeout == 0) { + msg::afailure("impossible d'arrêter la tâche"); + return; + } + } + $runfile->wfStopped(-778); + msg::asuccess(); + } +} diff --git a/php/src/cl.php b/php/src/cl.php index 2d11a48..448f6a5 100644 --- a/php/src/cl.php +++ b/php/src/cl.php @@ -2,6 +2,7 @@ namespace nulib; use ArrayAccess; +use nulib\php\func; use Traversable; /** @@ -12,19 +13,73 @@ use Traversable; * pour retourner un nouveau tableau */ class cl { + /** + * retourner un array avec les éléments retournés par l'itérateur. les clés + * numériques sont réordonnées, les clés chaine sont laissées en l'état + */ + static final function all(?iterable $iterable): array { + if ($iterable === null) return []; + if (is_array($iterable)) return $iterable; + $array = []; + foreach ($iterable as $key => $value) { + if (is_int($key)) $array[] = $value; + else $array[$key] = $value; + } + return $array; + } + + /** + * retourner la première valeur de $array ou $default si le tableau est null + * ou vide + */ + static final function first(?iterable $iterable, $default=null) { + if (is_array($iterable)) { + $key = array_key_first($iterable); + if ($key === null) return $default; + return $iterable[$key]; + } + if (is_iterable($iterable)) { + foreach ($iterable as $value) { + return $value; + } + } + return $default; + } + + /** + * retourner la dernière valeur de $array ou $default si le tableau est null + * ou vide + */ + static final function last(?iterable $iterable, $default=null) { + if (is_array($iterable)) { + $key = array_key_last($iterable); + if ($key === null) return $default; + return $iterable[$key]; + } + $value = $default; + if (is_iterable($iterable)) { + foreach ($iterable as $value) { + # parcourir tout l'iterateur pour avoir le dernier élément + } + } + return $value; + } + /** retourner un array non null à partir de $array */ static final function with($array): array { + if ($array instanceof IArrayWrapper) $array = $array->wrappedArray(); if (is_array($array)) return $array; elseif ($array === null || $array === false) return []; - elseif ($array instanceof Traversable) return iterator_to_array($array); + elseif ($array instanceof Traversable) return self::all($array); else return [$array]; } /** retourner un array à partir de $array, ou null */ static final function withn($array): ?array { + if ($array instanceof IArrayWrapper) $array = $array->wrappedArray(); if (is_array($array)) return $array; elseif ($array === null || $array === false) return null; - elseif ($array instanceof Traversable) return iterator_to_array($array); + elseif ($array instanceof Traversable) return self::all($array); else return [$array]; } @@ -82,6 +137,128 @@ class cl { return $default; } + /** + * retourner un tableau construit à partir des clés de $keys + * - [$to => $from] --> $dest[$to] = self::get($array, $from) + * - [$to => null] --> $dest[$to] = null + * - [$to => false] --> NOP + * - [$to] --> $dest[$to] = self::get($array, $to) + * - [null] --> $dest[] = null + * - [false] --> NOP + * + * Si $inverse===true, le mapping est inversé: + * - [$to => $from] --> $dest[$from] = self::get($array, $to) + * - [$to => null] --> $dest[$to] = self::get($array, $to) + * - [$to => false] --> NOP + * - [$to] --> $dest[$to] = self::get($array, $to) + * - [null] --> NOP (XXX que faire dans ce cas?) + * - [false] --> NOP + * + * notez que l'ordre est inversé par rapport à {@link self::rekey()} qui + * attend des mappings [$from => $to], alors que cette méthode attend des + * mappings [$to => $from] + */ + static final function select($array, ?array $mappings, bool $inverse=false): array { + $dest = []; + $index = 0; + if (!$inverse) { + foreach ($mappings as $to => $from) { + if ($to === $index) { + $index++; + $to = $from; + if ($to === false) continue; + elseif ($to === null) $dest[] = null; + else $dest[$to] = self::get($array, $to); + } elseif ($from === false) { + continue; + } elseif ($from === null) { + $dest[$to] = null; + } else { + $dest[$to] = self::get($array, $from); + } + } + } else { + foreach ($mappings as $to => $from) { + if ($to === $index) { + $index++; + $to = $from; + if ($to === false) continue; + elseif ($to === null) continue; + else $dest[$to] = self::get($array, $to); + } elseif ($from === false) { + continue; + } elseif ($from === null) { + $dest[$to] = self::get($array, $to); + } else { + $dest[$from] = self::get($array, $to); + } + } + } + return $dest; + } + + /** + * obtenir la liste des clés finalement obtenues après l'appel à + * {@link self::select()} avec le mapping spécifié + */ + static final function selected_keys(?array $mappings): array { + if ($mappings === null) return []; + $keys = []; + $index = 0; + foreach ($mappings as $to => $from) { + if ($to === $index) { + if ($from === false) continue; + elseif ($from === null) $keys[] = $index; + else $keys[] = $from; + $index++; + } elseif ($from === false) { + continue; + } else { + $keys[] = $to; + } + } + return $keys; + } + + /** + * méthode de convenance qui sélectionne certaines clés de $array avec + * {@link self::select()} puis merge le tableau $merge au résultat. + */ + static final function selectm($array, ?array $mappings, ?array $merge=null): array { + return cl::merge(self::select($array, $mappings), $merge); + } + + /** + * méthode de convenance qui merge $merge dans $array puis sélectionne + * certaines clés avec {@link self::select()} + */ + static final function mselect($array, ?array $merge, ?array $mappings): array { + return self::select(cl::merge($array, $merge), $mappings); + } + + /** + * construire un sous-ensemble du tableau $array en sélectionnant les clés de + * $includes qui ne sont pas mentionnées dans $excludes. + * + * - si $includes===null && $excludes===null, retourner le tableau inchangé + * - si $includes vaut null, prendre toutes les clés + * + */ + static final function xselect($array, ?array $includes, ?array $excludes=null): ?array { + if ($array === null) return null; + $array = self::withn($array); + if ($includes === null && $excludes === null) return $array; + if ($includes === null) $includes = array_keys($array); + if ($excludes === null) $excludes = []; + $result = []; + foreach ($array as $key => $value) { + if (!in_array($key, $includes)) continue; + if (in_array($key, $excludes)) continue; + $result[$key] = $value; + } + return $result; + } + /** * si $array est un array ou une instance de ArrayAccess, créer ou modifier * l'élément dont la clé est $key @@ -121,19 +298,11 @@ class cl { return $array !== null? array_keys($array): []; } - /** - * retourner la première valeur de $array ou $default si le tableau est null - * ou vide - */ - static final function first($array, $default=null) { - if (is_array($array)) return $array[array_key_first($array)]; - return $default; - } - ############################################################################# /** * Fusionner tous les tableaux spécifiés. Les valeurs null sont ignorées. + * IMPORTANT: les clés numériques sont réordonnées. * Retourner null si aucun tableau n'est fourni ou s'ils étaient tous null. */ static final function merge(...$arrays): ?array { @@ -145,6 +314,34 @@ class cl { return $merges? array_merge(...$merges): null; } + /** + * Fusionner tous les tableaux spécifiés. Les valeurs null sont ignorées. + * IMPORTANT: les clés numériques NE SONT PAS réordonnées. + * Retourner null si aucun tableau n'est fourni ou s'ils étaient tous null. + */ + static final function merge2(...$arrays): ?array { + $merged = null; + foreach ($arrays as $array) { + foreach (self::with($array) as $key => $value) { + $merged[$key] = $value; + } + } + return $merged; + } + + ############################################################################# + + static final function map(callable $callback, ?iterable $array): array { + $result = []; + if ($array !== null) { + $ctx = func::_prepare($callback); + foreach ($array as $key => $value) { + $result[$key] = func::_call($ctx, [$value, $key]); + } + } + return $result; + } + ############################################################################# /** @@ -236,6 +433,52 @@ class cl { return $result; } + /** + * retourner un tableau construit à partir des chemins de clé de $pkeys + * ces chemins peuvent être exprimés de plusieurs façon: + * - [$key => $pkey] --> $dest[$key] = self::pget($array, $pkey) + * - [$key => null] --> $dest[$key] = null + * - [$pkey] --> $dest[$key] = self::pget($array, $pkey) + * avec $key = implode("__", $pkey)) + * - [null] --> $dest[] = null + * - [false] --> NOP + */ + static final function pselect($array, ?array $pkeys): array { + $dest = []; + $index = 0; + foreach ($pkeys as $key => $pkey) { + if ($key === $index) { + $index++; + if ($pkey === null) continue; + $value = self::pget($array, $pkey); + if (!is_array($pkey)) $pkey = explode(".", strval($pkey)); + $key = implode("__", $pkey); + } elseif ($pkey === null) { + $value = null; + } else { + $value = self::pget($array, $pkey); + } + $dest[$key] = $value; + } + return $dest; + } + + /** + * méthode de convenance qui sélectionne certaines clés de $array avec + * {@link self::pselect()} puis merge le tableau $merge au résultat. + */ + static final function pselectm($array, ?array $pkeys, ?array $merge=null): array { + return cl::merge(self::pselect($array, $pkeys), $merge); + } + + /** + * méthode de convenance qui merge $merge dans $array puis sélectionne + * certaines clés avec {@link self::pselect()} + */ + static final function mpselect($array, ?array $merge, ?array $mappings): array { + return self::pselect(cl::merge($array, $merge), $mappings); + } + /** * modifier la valeur au chemin de clé $keys dans le tableau $array * @@ -353,9 +596,12 @@ class cl { /** * retourner le tableau $array en "renommant" les clés selon le tableau * $mappings qui contient des associations de la forme [$from => $to] + * + * Si $inverse===true, renommer dans le sens $to => $from */ - static function rekey(?array $array, ?array $mappings): ?array { + static function rekey(?array $array, ?array $mappings, bool $inverse=false): ?array { if ($array === null || $mappings === null) return $array; + if ($inverse) $mappings = array_flip($mappings); $mapped = []; foreach ($array as $key => $value) { if (array_key_exists($key, $mappings)) $key = $mappings[$key]; @@ -364,6 +610,19 @@ class cl { return $mapped; } + /** + * indiquer si {@link self::rekey()} modifierai le tableau indiqué (s'il y a + * des modifications à faire) + */ + static function would_rekey(?array $array, ?array $mappings, bool $inverse=false): bool { + if ($array === null || $mappings === null) return false; + if ($inverse) $mappings = array_flip($mappings); + foreach ($array as $key => $value) { + if (array_key_exists($key, $mappings)) return true; + } + return false; + } + ############################################################################# /** tester si tous les éléments du tableau satisfont la condition */ diff --git a/php/src/db/Capacitor.php b/php/src/db/Capacitor.php index 743cfd7..70c6f46 100644 --- a/php/src/db/Capacitor.php +++ b/php/src/db/Capacitor.php @@ -1,23 +1,129 @@ storage = $storage; $this->channel = $channel; + $this->channel->setCapacitor($this); if ($ensureExists) $this->ensureExists(); } /** @var CapacitorStorage */ protected $storage; + function getStorage(): CapacitorStorage { + return $this->storage; + } + + function db(): IDatabase { + return $this->getStorage()->db(); + } + /** @var CapacitorChannel */ protected $channel; + function getChannel(): CapacitorChannel { + return $this->channel; + } + + function getTableName(): string { + return $this->getChannel()->getTableName(); + } + + /** @var CapacitorChannel[] */ + protected ?array $subChannels = null; + + protected ?array $subManageTransactions = null; + + function willUpdate(...$channels): self { + if ($this->subChannels === null) { + # désactiver la gestion des transaction sur le channel local aussi + $this->subChannels[] = $this->channel; + } + if ($channels) { + foreach ($channels as $channel) { + if ($channel instanceof Capacitor) $channel = $channel->getChannel(); + if ($channel instanceof CapacitorChannel) { + $this->subChannels[] = $channel; + } else { + throw ValueException::invalid_type($channel, CapacitorChannel::class); + } + } + } + return $this; + } + + function inTransaction(): bool { + return $this->db()->inTransaction(); + } + + function beginTransaction(?callable $func=null, bool $commit=true): void { + $db = $this->db(); + if ($this->subChannels !== null) { + # on gère des subchannels: ne débuter la transaction que si ce n'est déjà fait + if ($this->subManageTransactions === null) { + foreach ($this->subChannels as $channel) { + $name = $channel->getName(); + $this->subManageTransactions ??= []; + if (!array_key_exists($name, $this->subManageTransactions)) { + $this->subManageTransactions[$name] = $channel->isManageTransactions(); + } + $channel->setManageTransactions(false); + } + if (!$db->inTransaction()) $db->beginTransaction(); + } + } elseif (!$db->inTransaction()) { + $db->beginTransaction(); + } + if ($func !== null) { + $commited = false; + try { + func::call($func, $this); + if ($commit) { + $this->commit(); + $commited = true; + } + } finally { + if ($commit && !$commited) $this->rollback(); + } + } + } + + protected function beforeEndTransaction(): void { + if ($this->subManageTransactions !== null) { + foreach ($this->subChannels as $channel) { + $name = $channel->getName(); + $channel->setManageTransactions($this->subManageTransactions[$name]); + } + $this->subManageTransactions = null; + } + } + + function commit(): void { + $this->beforeEndTransaction(); + $db = $this->db(); + if ($db->inTransaction()) $db->commit(); + } + + function rollback(): void { + $this->beforeEndTransaction(); + $db = $this->db(); + if ($db->inTransaction()) $db->rollback(); + } + + function getCreateSql(): string { + return $this->storage->_getCreateSql($this->channel); + } + function exists(): bool { return $this->storage->_exists($this->channel); } @@ -26,15 +132,16 @@ class Capacitor { $this->storage->_ensureExists($this->channel); } - function reset(): void { - $this->storage->_reset($this->channel); + function reset(bool $recreate=false): void { + $this->storage->_reset($this->channel, $recreate); } - function charge($item, ?callable $func=null, ?array $args=null): int { - return $this->storage->_charge($this->channel, $item, $func, $args); + function charge($item, $func=null, ?array $args=null, ?array &$values=null): int { + if ($this->subChannels !== null) $this->beginTransaction(); + return $this->storage->_charge($this->channel, $item, $func, $args, $values); } - function discharge($filter=null, ?bool $reset=null): iterable { + function discharge(bool $reset=true): Traversable { return $this->storage->_discharge($this->channel, $reset); } @@ -42,16 +149,22 @@ class Capacitor { return $this->storage->_count($this->channel, $filter); } - function one($filter): ?array { - return $this->storage->_one($this->channel, $filter); + function one($filter, ?array $mergeQuery=null): ?array { + return $this->storage->_one($this->channel, $filter, $mergeQuery); } - function all($filter): iterable { - return $this->storage->_all($this->channel, $filter); + function all($filter, ?array $mergeQuery=null): Traversable { + return $this->storage->_all($this->channel, $filter, $mergeQuery); } - function each($filter, ?callable $func=null, ?array $args=null): int { - return $this->storage->_each($this->channel, $filter, $func, $args); + function each($filter, $func=null, ?array $args=null, ?array $mergeQuery=null, ?int &$nbUpdated=null): int { + if ($this->subChannels !== null) $this->beginTransaction(); + return $this->storage->_each($this->channel, $filter, $func, $args, $mergeQuery, $nbUpdated); + } + + function delete($filter, $func=null, ?array $args=null): int { + if ($this->subChannels !== null) $this->beginTransaction(); + return $this->storage->_delete($this->channel, $filter, $func, $args); } function close(): void { diff --git a/php/src/db/CapacitorChannel.php b/php/src/db/CapacitorChannel.php index 82620a3..8ce832f 100644 --- a/php/src/db/CapacitorChannel.php +++ b/php/src/db/CapacitorChannel.php @@ -1,48 +1,166 @@ name = self::verifix_name($name ?? static::NAME); - $this->eachCommitThreshold = $eachCommitThreshold ?? static::EACH_COMMIT_THRESHOLD; + protected static function verifix_eachCommitThreshold(?int $eachCommitThreshold): ?int { + $eachCommitThreshold ??= static::EACH_COMMIT_THRESHOLD; + if ($eachCommitThreshold < 0) $eachCommitThreshold = null; + return $eachCommitThreshold; + } + + function __construct(?string $name=null, ?int $eachCommitThreshold=null, ?bool $manageTransactions=null) { + $name ??= static::NAME; + $tableName ??= static::TABLE_NAME; + self::verifix_name($name, $tableName); + $this->name = $name; + $this->tableName = $tableName; + $this->manageTransactions = $manageTransactions ?? static::MANAGE_TRANSACTIONS; + $this->eachCommitThreshold = self::verifix_eachCommitThreshold($eachCommitThreshold); + $this->useCache = static::USE_CACHE; + $this->setup = false; $this->created = false; + $columnDefinitions = cl::withn(static::COLUMN_DEFINITIONS); + $primaryKeys = cl::withn(static::PRIMARY_KEYS); + if ($primaryKeys === null && $columnDefinitions !== null) { + $index = 0; + foreach ($columnDefinitions as $col => $def) { + if ($col === $index) { + $index++; + if (preg_match('/\bprimary\s+key\s+\((.+)\)/i', $def, $ms)) { + $primaryKeys = preg_split('/\s*,\s*/', trim($ms[1])); + } + } else { + if (preg_match('/\bprimary\s+key\b/i', $def)) { + $primaryKeys[] = $col; + } + } + } + } + $this->columnDefinitions = $columnDefinitions; + $this->primaryKeys = $primaryKeys; } - /** @var string */ - protected $name; + protected string $name; function getName(): string { return $this->name; } + protected string $tableName; + + function getTableName(): string { + return $this->tableName; + } + + /** + * @var bool indiquer si les modifications de each doivent être gérées dans + * une transaction. si false, l'utilisateur doit lui même gérer la + * transaction. + */ + protected bool $manageTransactions; + + function isManageTransactions(): bool { + return $this->manageTransactions; + } + + function setManageTransactions(bool $manageTransactions=true): self { + $this->manageTransactions = $manageTransactions; + return $this; + } + /** * @var ?int nombre maximum de modifications dans une transaction avant un * commit automatique dans {@link Capacitor::each()}. Utiliser null pour * désactiver la fonctionnalité. + * + * ce paramètre n'a d'effet que si $manageTransactions==true */ - protected $eachCommitThreshold; + protected ?int $eachCommitThreshold; function getEachCommitThreshold(): ?int { return $this->eachCommitThreshold; } - function getTableName(): string { - return $this->name."_channel"; + function setEachCommitThreshold(?int $eachCommitThreshold=null): self { + $this->eachCommitThreshold = self::verifix_eachCommitThreshold($eachCommitThreshold); + return $this; } - protected $created; + /** + * @var bool faut-il passer par le cache pour les requêtes de all(), each() + * et delete()? + * ça peut être nécessaire avec MySQL/MariaDB si on utilise les requêtes non + * bufférisées, et que la fonction manipule la base de données + */ + protected bool $useCache; + + function isUseCache(): bool { + return $this->useCache; + } + + function setUseCache(bool $useCache=true): self { + $this->useCache = $useCache; + return $this; + } + + /** + * initialiser ce channel avant sa première utilisation. + */ + protected function setup(): void { + } + + protected bool $setup; + + function ensureSetup() { + if (!$this->setup) { + $this->setup(); + $this->setup = true; + } + } + + protected bool $created; function isCreated(): bool { return $this->created; @@ -52,13 +170,15 @@ class CapacitorChannel { $this->created = $created; } + protected ?array $columnDefinitions; + /** * retourner un ensemble de définitions pour des colonnes supplémentaires à * insérer lors du chargement d'une valeur * * la clé primaire "id_" a pour définition "integer primary key autoincrement". * elle peut être redéfinie, et dans ce cas la valeur à utiliser doit être - * retournée par {@link getKeyValues()} + * retournée par {@link getItemValues()} * * la colonne "item__" contient la valeur sérialisée de l'élément chargé. bien * que ce soit possible techniquement, cette colonne n'a pas à être redéfinie @@ -68,46 +188,143 @@ class CapacitorChannel { * lors de l'insertion dans la base de données, et automatiquement désérialisées * avant d'être retournées à l'utilisateur (sans le suffixe "__") */ - function getKeyDefinitions(): ?array { - return null; + function getColumnDefinitions(): ?array { + return $this->columnDefinitions; + } + + protected ?array $primaryKeys; + + function getPrimaryKeys(): ?array { + return $this->primaryKeys; } /** * calculer les valeurs des colonnes supplémentaires à insérer pour le - * chargement de $item + * chargement de $item. pour une même valeur de $item, la valeur de retour + * doit toujours être la même. pour rajouter des valeurs supplémentaires qui + * dépendent de l'environnement, il faut plutôt les retournner dans + * {@link self::onCreate()} ou {@link self::onUpdate()} * - * Cette méthode est utilisée par {@link Capacitor::charge()}. Si une valeur - * "id_" est retourné, la ligne correspondate existante est mise à jour + * Cette méthode est utilisée par {@link Capacitor::charge()}. Si la clé + * primaire est incluse (il s'agit généralement de "id_"), la ligne + * correspondate est mise à jour si elle existe. + * Retourner la clé primaire par cette méthode est l'unique moyen de + * déclencher une mise à jour plutôt qu'une nouvelle création. + * + * Retourner [false] pour annuler le chargement */ - function getKeyValues($item): ?array { + function getItemValues($item): ?array { return null; } /** * Avant d'utiliser un id pour rechercher dans la base de donnée, corriger sa * valeur le cas échéant. + * + * Cette fonction assume que la clé primaire n'est pas multiple. Elle n'est + * pas utilisée si une clé primaire multiple est définie. */ function verifixId(string &$id): void { } /** - * méthode appelée lors du chargement d'un élément avec - * {@link Capacitor::charge()} + * retourne true si un nouvel élément ou un élément mis à jour a été chargé. + * false si l'élément chargé est identique au précédent. + * + * cette méthode doit être utilisée dans {@link self::onUpdate()} + */ + function wasRowModified(array $values, array $pvalues): bool { + return $values["item__sum_"] !== $pvalues["item__sum_"]; + } + + final function serialize($item): ?string { + return $item !== null? serialize($item): null; + } + + final function unserialize(?string $serial) { + return $serial !== null? unserialize($serial): null; + } + + const SERIAL_DEFINITION = "mediumtext"; + const SUM_DEFINITION = "varchar(40)"; + + final function sum(?string $serial, $value=null): ?string { + if ($serial === null) $serial = $this->serialize($value); + return $serial !== null? sha1($serial): null; + } + + final function isSerialCol(string &$key): bool { + return str::del_suffix($key, "__"); + } + + final function getSumCols(string $key): array { + return ["${key}__", "${key}__sum_"]; + } + + function getSum(string $key, $value): array { + $sumCols = $this->getSumCols($key); + $serial = $this->serialize($value); + $sum = $this->sum($serial, $value); + return array_combine($sumCols, [$serial, $sum]); + } + + function wasSumModified(string $key, $value, array $pvalues): bool { + $sumCol = $this->getSumCols($key)[1]; + $sum = $this->sum(null, $value); + $psum = $pvalues[$sumCol] ?? $this->sum(null, $pvalues[$key] ?? null); + return $sum !== $psum; + } + + function _wasSumModified(string $key, array $row, array $prow): bool { + $sumCol = $this->getSumCols($key)[1]; + $sum = $row[$sumCol] ?? null; + $psum = $prow[$sumCol] ?? null; + return $sum !== $psum; + } + + /** + * méthode appelée lors du chargement avec {@link Capacitor::charge()} pour + * créer un nouvel élément * * @param mixed $item l'élément à charger - * @param array $updates les valeurs calculées par {@link getKeyValues()} - * @param ?array $row la ligne à mettre à jour. vaut null s'il faut insérer - * une nouvelle ligne - * @return ?array le cas échéant, un tableau non null à merger dans $updates - * et utilisé pour provisionner la ligne nouvellement créée, ou mettre à jour - * la ligne existante + * @param array $values la ligne à créer, calculée à partir de $item et des + * valeurs retournées par {@link getItemValues()} + * @return ?array le cas échéant, un tableau non null à merger dans $values et + * utilisé pour provisionner la ligne nouvellement créée. + * Retourner [false] pour annuler le chargement (la ligne n'est pas créée) * * Si $item est modifié dans cette méthode, il est possible de le retourner * avec la clé "item" pour mettre à jour la ligne correspondante. - * La colonne "id_" ne peut pas être modifiée: si "id_" est retourné, il est - * ignoré + * + * la création ou la mise à jour est uniquement décidée en fonction des + * valeurs calculées par {@link self::getItemValues()}. Bien que cette méthode + * peut techniquement retourner de nouvelles valeurs pour la clé primaire, ça + * risque de créer des doublons */ - function onCharge($item, array $updates, ?array $row): ?array { + function onCreate($item, array $values, ?array $alwaysNull): ?array { + return null; + } + + /** + * méthode appelée lors du chargement avec {@link Capacitor::charge()} pour + * mettre à jour un élément existant + * + * @param mixed $item l'élément à charger + * @param array $values la nouvelle ligne, calculée à partir de $item et + * des valeurs retournées par {@link getItemValues()} + * @param array $pvalues la précédente ligne, chargée depuis la base de + * données + * @return ?array null s'il ne faut pas mettre à jour la ligne. sinon, ce + * tableau est mergé dans $values puis utilisé pour mettre à jour la ligne + * existante + * Retourner [false] pour annuler le chargement (la ligne n'est pas mise à + * jour) + * + * - Il est possible de mettre à jour $item en le retourant avec la clé "item" + * - La clé primaire (il s'agit généralement de "id_") ne peut pas être + * modifiée. si elle est retournée, elle est ignorée + */ + function onUpdate($item, array $values, array $pvalues): ?array { return null; } @@ -116,16 +333,75 @@ class CapacitorChannel { * {@link Capacitor::each()} * * @param mixed $item l'élément courant - * @param ?array $row la ligne à mettre à jour. + * @param ?array $values la ligne courante * @return ?array le cas échéant, un tableau non null utilisé pour mettre à * jour la ligne courante * - * Si $item est modifié dans cette méthode, il est possible de le retourner - * avec la clé "item" pour mettre à jour la ligne correspondante - * La colonne "id_" ne peut pas être modifiée: si "id_" est retourné, il est - * ignoré + * - Il est possible de mettre à jour $item en le retourant avec la clé "item" + * - La clé primaire (il s'agit généralement de "id_") ne peut pas être + * modifiée. si elle est retournée, elle est ignorée */ - function onEach($item, array $row): ?array { + function onEach($item, array $values): ?array { return null; } + const onEach = "->".[self::class, "onEach"][1]; + + /** + * méthode appelée lors du parcours des éléments avec + * {@link Capacitor::delete()} + * + * @param mixed $item l'élément courant + * @param ?array $values la ligne courante + * @return bool true s'il faut supprimer la ligne, false sinon + */ + function onDelete($item, array $values): bool { + return true; + } + const onDelete = "->".[self::class, "onDelete"][1]; + + ############################################################################# + # Méthodes déléguées pour des workflows centrés sur le channel + + /** + * @var Capacitor|null instance de Capacitor par laquelle cette instance est + * utilisée + */ + protected ?Capacitor $capacitor; + + function getCapacitor(): ?Capacitor { + return $this->capacitor; + } + + function setCapacitor(Capacitor $capacitor): self { + $this->capacitor = $capacitor; + return $this; + } + + function charge($item, $func=null, ?array $args=null, ?array &$values=null): int { + return $this->capacitor->charge($item, $func, $args, $values); + } + + function discharge(bool $reset=true): Traversable { + return $this->capacitor->discharge($reset); + } + + function count($filter=null): int { + return $this->capacitor->count($filter); + } + + function one($filter, ?array $mergeQuery=null): ?array { + return $this->capacitor->one($filter, $mergeQuery); + } + + function all($filter, ?array $mergeQuery=null): Traversable { + return $this->capacitor->all($filter, $mergeQuery); + } + + function each($filter, $func=null, ?array $args=null, ?array $mergeQuery=null, ?int &$nbUpdated=null): int { + return $this->capacitor->each($filter, $func, $args, $mergeQuery, $nbUpdated); + } + + function delete($filter, $func=null, ?array $args=null): int { + return $this->capacitor->delete($filter, $func, $args); + } } diff --git a/php/src/db/CapacitorStorage.php b/php/src/db/CapacitorStorage.php index 110cb29..a257aee 100644 --- a/php/src/db/CapacitorStorage.php +++ b/php/src/db/CapacitorStorage.php @@ -2,104 +2,622 @@ namespace nulib\db; use nulib\cl; +use nulib\db\cache\cache; use nulib\php\func; +use nulib\ValueException; +use Traversable; /** * Class CapacitorStorage: objet permettant d'accumuler des données pour les * réutiliser plus tard */ abstract class CapacitorStorage { - abstract protected function getChannel(?string $name): CapacitorChannel; + abstract function db(): IDatabase; - abstract function _exists(CapacitorChannel $channel): bool; + /** @var CapacitorChannel[] */ + protected $channels; + + function addChannel(CapacitorChannel $channel): CapacitorChannel { + $this->_create($channel); + $this->channels[$channel->getName()] = $channel; + return $channel; + } + + protected function getChannel(?string $name): CapacitorChannel { + CapacitorChannel::verifix_name($name); + $channel = $this->channels[$name] ?? null; + if ($channel === null) { + $channel = $this->addChannel(new CapacitorChannel($name)); + } + return $channel; + } + + /** DOIT être défini dans les classes dérivées */ + const PRIMARY_KEY_DEFINITION = null; + + const COLUMN_DEFINITIONS = [ + "item__" => CapacitorChannel::SERIAL_DEFINITION, + "item__sum_" => CapacitorChannel::SUM_DEFINITION, + "created_" => "datetime", + "modified_" => "datetime", + ]; + + protected function ColumnDefinitions(CapacitorChannel $channel): array { + $definitions = []; + if ($channel->getPrimaryKeys() === null) { + $definitions[] = static::PRIMARY_KEY_DEFINITION; + } + $definitions[] = $channel->getColumnDefinitions(); + $definitions[] = static::COLUMN_DEFINITIONS; + # forcer les définitions sans clé à la fin (sqlite requière par exemple que + # primary key (columns) soit à la fin) + $tmp = cl::merge(...$definitions); + $definitions = []; + $constraints = []; + $index = 0; + foreach ($tmp as $col => $def) { + if ($col === $index) { + $index++; + $constraints[] = $def; + } else { + $definitions[$col] = $def; + } + } + return cl::merge($definitions, $constraints); + } + + /** sérialiser les valeurs qui doivent l'être dans $values */ + protected function serialize(CapacitorChannel $channel, ?array $values): ?array { + if ($values === null) return null; + $cols = $this->ColumnDefinitions($channel); + $index = 0; + $row = []; + foreach (array_keys($cols) as $col) { + $key = $col; + if ($key === $index) { + $index++; + } elseif ($channel->isSerialCol($key)) { + [$serialCol, $sumCol] = $channel->getSumCols($key); + if (array_key_exists($key, $values)) { + $sum = $channel->getSum($key, $values[$key]); + $row[$serialCol] = $sum[$serialCol]; + if (array_key_exists($sumCol, $cols)) { + $row[$sumCol] = $sum[$sumCol]; + } + } + } elseif (array_key_exists($key, $values)) { + $row[$col] = $values[$key]; + } + } + return $row; + } + + /** désérialiser les valeurs qui doivent l'être dans $values */ + protected function unserialize(CapacitorChannel $channel, ?array $row): ?array { + if ($row === null) return null; + $cols = $this->ColumnDefinitions($channel); + $index = 0; + $values = []; + foreach (array_keys($cols) as $col) { + $key = $col; + if ($key === $index) { + $index++; + } elseif (!array_key_exists($col, $row)) { + } elseif ($channel->isSerialCol($key)) { + $value = $row[$col]; + if ($value !== null) $value = $channel->unserialize($value); + $values[$key] = $value; + } else { + $values[$key] = $row[$col]; + } + } + return $values; + } + + function getPrimaryKeys(CapacitorChannel $channel): array { + $primaryKeys = $channel->getPrimaryKeys(); + if ($primaryKeys === null) $primaryKeys = ["id_"]; + return $primaryKeys; + } + + function getRowIds(CapacitorChannel $channel, ?array $row, ?array &$primaryKeys=null): ?array { + $primaryKeys = $this->getPrimaryKeys($channel); + $rowIds = cl::select($row, $primaryKeys); + if (cl::all_n($rowIds)) return null; + else return $rowIds; + } + + protected function _createSql(CapacitorChannel $channel): array { + $cols = $this->ColumnDefinitions($channel); + return [ + "create table if not exists", + "table" => $channel->getTableName(), + "cols" => $cols, + ]; + } + + protected static function format_sql(CapacitorChannel $channel, string $sql): string { + $class = get_class($channel); + return <<_getCreateSql($this->getChannel($channel)); + } + + protected function _create(CapacitorChannel $channel): void { + $channel->ensureSetup(); + if (!$channel->isCreated()) { + $this->db->exec($this->_createSql($channel)); + $channel->setCreated(); + } + } /** tester si le canal spécifié existe */ + abstract function _exists(CapacitorChannel $channel): bool; + function exists(?string $channel): bool { return $this->_exists($this->getChannel($channel)); } - abstract function _ensureExists(CapacitorChannel $channel): void; - /** s'assurer que le canal spécifié existe */ + function _ensureExists(CapacitorChannel $channel): void { + $this->_create($channel); + } + function ensureExists(?string $channel): void { $this->_ensureExists($this->getChannel($channel)); } - abstract function _reset(CapacitorChannel $channel): void; - /** supprimer le canal spécifié */ - function reset(?string $channel): void { - $this->_reset($this->getChannel($channel)); + function _reset(CapacitorChannel $channel, bool $recreate=false): void { + $this->db->exec([ + "drop table if exists", + $channel->getTableName(), + ]); + $channel->setCreated(false); + if ($recreate) $this->_ensureExists($channel); } - abstract function _charge(CapacitorChannel $channel, $item, ?callable $func, ?array $args): int; + function reset(?string $channel, bool $recreate=false): void { + $this->_reset($this->getChannel($channel), $recreate); + } /** * charger une valeur dans le canal * - * Si $func!==null, après avoir calculé les valeurs des clés supplémentaires - * avec {@link CapacitorChannel::getKeyValues()}, la fonction est appelée avec - * la signature ($item, $keyValues, $row, ...$args) - * Si la fonction retourne un tableau, il est utilisé pour modifier les valeurs - * insérées/mises à jour + * Après avoir calculé les valeurs des clés supplémentaires + * avec {@link CapacitorChannel::getItemValues()}, l'une des deux fonctions + * {@link CapacitorChannel::onCreate()} ou {@link CapacitorChannel::onUpdate()} + * est appelée en fonction du type d'opération: création ou mise à jour + * + * Ensuite, si $func !== null, la fonction est appelée avec la signature de + * {@link CapacitorChannel::onCreate()} ou {@link CapacitorChannel::onUpdate()} + * en fonction du type d'opération: création ou mise à jour + * + * Dans les deux cas, si la fonction retourne un tableau, il est utilisé pour + * modifier les valeurs insérées/mises à jour. De plus, $values obtient la + * valeur finale des données insérées/mises à jour + * + * Si $args est renseigné, il est ajouté aux arguments utilisés pour appeler + * les méthodes {@link CapacitorChannel::getItemValues()}, + * {@link CapacitorChannel::onCreate()} et/ou + * {@link CapacitorChannel::onUpdate()} * * @return int 1 si l'objet a été chargé ou mis à jour, 0 s'il existait * déjà à l'identique dans le canal */ - function charge(?string $channel, $item, ?callable $func=null, ?array $args=null): int { - return $this->_charge($this->getChannel($channel), $item, $func, $args); + function _charge(CapacitorChannel $channel, $item, $func, ?array $args, ?array &$values=null): int { + $this->_create($channel); + $tableName = $channel->getTableName(); + $db = $this->db(); + $args ??= []; + + $initFunc = [$channel, "getItemValues"]; + $initArgs = $args; + func::ensure_func($initFunc, null, $initArgs); + $values = func::call($initFunc, $item, ...$initArgs); + if ($values === [false]) return 0; + + $row = cl::merge( + $channel->getSum("item", $item), + $this->serialize($channel, $values)); + $prow = null; + $rowIds = $this->getRowIds($channel, $row, $primaryKeys); + if ($rowIds !== null) { + # modification + $prow = $db->one([ + "select", + "from" => $tableName, + "where" => $rowIds, + ]); + } + + $now = date("Y-m-d H:i:s"); + $insert = null; + if ($prow === null) { + # création + $row = cl::merge($row, [ + "created_" => $now, + "modified_" => $now, + ]); + $insert = true; + $initFunc = [$channel, "onCreate"]; + $initArgs = $args; + func::ensure_func($initFunc, null, $initArgs); + $values = $this->unserialize($channel, $row); + $pvalues = null; + } else { + # modification + # intégrer autant que possible les valeurs de prow dans row, de façon que + # l'utilisateur puisse voir clairement ce qui a été modifié + if ($channel->_wasSumModified("item", $row, $prow)) { + $insert = false; + $row = cl::merge($prow, $row, [ + "modified_" => $now, + ]); + } else { + $row = cl::merge($prow, $row); + } + $initFunc = [$channel, "onUpdate"]; + $initArgs = $args; + func::ensure_func($initFunc, null, $initArgs); + $values = $this->unserialize($channel, $row); + $pvalues = $this->unserialize($channel, $prow); + } + + $updates = func::call($initFunc, $item, $values, $pvalues, ...$initArgs); + if ($updates === [false]) return 0; + if (is_array($updates) && $updates) { + if ($insert === null) $insert = false; + if (!array_key_exists("modified_", $updates)) { + $updates["modified_"] = $now; + } + $values = cl::merge($values, $updates); + $row = cl::merge($row, $this->serialize($channel, $updates)); + } + + if ($func !== null) { + func::ensure_func($func, $channel, $args); + $updates = func::call($func, $item, $values, $pvalues, ...$args); + if ($updates === [false]) return 0; + if (is_array($updates) && $updates) { + if ($insert === null) $insert = false; + if (!array_key_exists("modified_", $updates)) { + $updates["modified_"] = $now; + } + $values = cl::merge($values, $updates); + $row = cl::merge($row, $this->serialize($channel, $updates)); + } + } + + # aucune modification + if ($insert === null) return 0; + + # si on est déjà dans une transaction, désactiver la gestion des transactions + $manageTransactions = $channel->isManageTransactions() && !$db->inTransaction(); + if ($manageTransactions) { + $commited = false; + $db->beginTransaction(); + } + $nbModified = 0; + try { + if ($insert) { + $id = $db->exec([ + "insert", + "into" => $tableName, + "values" => $row, + ]); + if (count($primaryKeys) == 1 && $rowIds === null) { + # mettre à jour avec l'id généré + $values[$primaryKeys[0]] = $id; + } + $nbModified = 1; + } else { + # calculer ce qui a changé pour ne mettre à jour que le nécessaire + $updates = []; + foreach ($row as $col => $value) { + if (array_key_exists($col, $rowIds)) { + # ne jamais mettre à jour la clé primaire + continue; + } + $pvalue = $prow[$col] ?? null; + if ($value !== ($pvalue)) { + $updates[$col] = $value; + } + } + if (count($updates) == 1 && array_key_first($updates) == "modified_") { + # si l'unique modification porte sur la date de modification, alors + # la ligne n'est pas modifiée. ce cas se présente quand on altère la + # valeur de $item + $updates = null; + } + if ($updates) { + $db->exec([ + "update", + "table" => $tableName, + "values" => $updates, + "where" => $rowIds, + ]); + $nbModified = 1; + } + } + if ($manageTransactions) { + $db->commit(); + $commited = true; + } + return $nbModified; + } finally { + if ($manageTransactions && !$commited) $db->rollback(); + } } - abstract function _discharge(CapacitorChannel $channel, bool $reset=true): iterable; + function charge(?string $channel, $item, $func=null, ?array $args=null, ?array &$values=null): int { + return $this->_charge($this->getChannel($channel), $item, $func, $args, $values); + } /** décharger les données du canal spécifié */ - function discharge(?string $channel, bool $reset=true): iterable { + function _discharge(CapacitorChannel $channel, bool $reset=true): Traversable { + $this->_create($channel); + $rows = $this->db()->all([ + "select item__", + "from" => $channel->getTableName(), + ]); + foreach ($rows as $row) { + yield unserialize($row['item__']); + } + if ($reset) $this->_reset($channel); + } + + function discharge(?string $channel, bool $reset=true): Traversable { return $this->_discharge($this->getChannel($channel), $reset); } - abstract function _count(CapacitorChannel $channel, $filter): int; + protected function _convertValue2row(CapacitorChannel $channel, array $filter, array $cols): array { + $index = 0; + $fixed = []; + foreach ($filter as $key => $value) { + if ($key === $index) { + $index++; + if (is_array($value)) { + $value = $this->_convertValue2row($channel, $value, $cols); + } + $fixed[] = $value; + } else { + $col = "${key}__"; + if (array_key_exists($col, $cols)) { + # colonne sérialisée + $fixed[$col] = $channel->serialize($value); + } else { + $fixed[$key] = $value; + } + } + } + return $fixed; + } + + protected function verifixFilter(CapacitorChannel $channel, &$filter): void { + if ($filter !== null && !is_array($filter)) { + $primaryKeys = $this->getPrimaryKeys($channel); + $id = $filter; + $channel->verifixId($id); + $filter = [$primaryKeys[0] => $id]; + } + $cols = $this->ColumnDefinitions($channel); + if ($filter !== null) { + $filter = $this->_convertValue2row($channel, $filter, $cols); + } + } /** indiquer le nombre d'éléments du canal spécifié */ + function _count(CapacitorChannel $channel, $filter): int { + $this->_create($channel); + $this->verifixFilter($channel, $filter); + return $this->db()->get([ + "select count(*)", + "from" => $channel->getTableName(), + "where" => $filter, + ]); + } + function count(?string $channel, $filter=null): int { return $this->_count($this->getChannel($channel), $filter); } - abstract function _one(CapacitorChannel $channel, $filter): ?array; - /** * obtenir la ligne correspondant au filtre sur le canal spécifié * * si $filter n'est pas un tableau, il est transformé en ["id_" => $filter] */ - function one(?string $channel, $filter): ?array { - return $this->_one($this->getChannel($channel), $filter); + function _one(CapacitorChannel $channel, $filter, ?array $mergeQuery=null): ?array { + if ($filter === null) throw ValueException::null("filter"); + $this->_create($channel); + $this->verifixFilter($channel, $filter); + $row = $this->db()->one(cl::merge([ + "select", + "from" => $channel->getTableName(), + "where" => $filter, + ], $mergeQuery)); + return $this->unserialize($channel, $row); } - abstract function _all(CapacitorChannel $channel, $filter): iterable; + function one(?string $channel, $filter, ?array $mergeQuery=null): ?array { + return $this->_one($this->getChannel($channel), $filter, $mergeQuery); + } + + private function _allCached(string $id, CapacitorChannel $channel, $filter, ?array $mergeQuery=null): Traversable { + $this->_create($channel); + $this->verifixFilter($channel, $filter); + $rows = $this->db()->all(cl::merge([ + "select", + "from" => $channel->getTableName(), + "where" => $filter, + ], $mergeQuery), null, $this->getPrimaryKeys($channel)); + if ($channel->isUseCache()) { + $cacheIds = [$id, get_class($channel)]; + cache::get()->resetCached($cacheIds); + $rows = cache::new(null, $cacheIds, function() use ($rows) { + yield from $rows; + }); + } + foreach ($rows as $key => $row) { + yield $key => $this->unserialize($channel, $row); + } + } /** * obtenir les lignes correspondant au filtre sur le canal spécifié * * si $filter n'est pas un tableau, il est transformé en ["id_" => $filter] */ - function all(?string $channel, $filter): iterable { - return $this->_one($this->getChannel($channel), $filter); + function _all(CapacitorChannel $channel, $filter, ?array $mergeQuery=null): Traversable { + return $this->_allCached("all", $channel, $filter, $mergeQuery); } - abstract function _each(CapacitorChannel $channel, $filter, ?callable $func, ?array $args): int; + function all(?string $channel, $filter, $mergeQuery=null): Traversable { + return $this->_all($this->getChannel($channel), $filter, $mergeQuery); + } /** * appeler une fonction pour chaque élément du canal spécifié. * * $filter permet de filtrer parmi les élements chargés * - * $func est appelé avec la signature ($item, $row, ...$args). si la fonction - * retourne un tableau, il est utilisé pour mettre à jour la ligne + * $func est appelé avec la signature de {@link CapacitorChannel::onEach()} + * si la fonction retourne un tableau, il est utilisé pour mettre à jour la + * ligne + * + * @param int $nbUpdated reçoit le nombre de lignes mises à jour + * @return int le nombre de lignes parcourues + */ + function _each(CapacitorChannel $channel, $filter, $func, ?array $args, ?array $mergeQuery=null, ?int &$nbUpdated=null): int { + $this->_create($channel); + if ($func === null) $func = CapacitorChannel::onEach; + func::ensure_func($func, $channel, $args); + $onEach = func::_prepare($func); + $db = $this->db(); + # si on est déjà dans une transaction, désactiver la gestion des transactions + $manageTransactions = $channel->isManageTransactions() && !$db->inTransaction(); + if ($manageTransactions) { + $commited = false; + $db->beginTransaction(); + $commitThreshold = $channel->getEachCommitThreshold(); + } + $count = 0; + $nbUpdated = 0; + $tableName = $channel->getTableName(); + try { + $args ??= []; + $all = $this->_allCached("each", $channel, $filter, $mergeQuery); + foreach ($all as $values) { + $rowIds = $this->getRowIds($channel, $values); + $updates = func::_call($onEach, [$values["item"], $values, ...$args]); + if (is_array($updates) && $updates) { + if (!array_key_exists("modified_", $updates)) { + $updates["modified_"] = date("Y-m-d H:i:s"); + } + $nbUpdated += $db->exec([ + "update", + "table" => $tableName, + "values" => $this->serialize($channel, $updates), + "where" => $rowIds, + ]); + if ($manageTransactions && $commitThreshold !== null) { + $commitThreshold--; + if ($commitThreshold <= 0) { + $db->commit(); + $db->beginTransaction(); + $commitThreshold = $channel->getEachCommitThreshold(); + } + } + } + $count++; + } + if ($manageTransactions) { + $db->commit(); + $commited = true; + } + return $count; + } finally { + if ($manageTransactions && !$commited) $db->rollback(); + } + } + + function each(?string $channel, $filter, $func=null, ?array $args=null, ?array $mergeQuery=null, ?int &$nbUpdated=null): int { + return $this->_each($this->getChannel($channel), $filter, $func, $args, $mergeQuery, $nbUpdated); + } + + /** + * supprimer tous les éléments correspondant au filtre et pour lesquels la + * fonction retourne une valeur vraie si elle est spécifiée + * + * $filter permet de filtrer parmi les élements chargés + * + * $func est appelé avec la signature de {@link CapacitorChannel::onDelete()} + * si la fonction retourne un tableau, il est utilisé pour mettre à jour la + * ligne * * @return int le nombre de lignes parcourues */ - function each(?string $channel, $filter, ?callable $func=null, ?array $args=null): int { - return $this->_each($this->getChannel($channel), $filter, $func, $args); + function _delete(CapacitorChannel $channel, $filter, $func, ?array $args): int { + $this->_create($channel); + if ($func === null) $func = CapacitorChannel::onDelete; + func::ensure_func($func, $channel, $args); + $onEach = func::_prepare($func); + $db = $this->db(); + # si on est déjà dans une transaction, désactiver la gestion des transactions + $manageTransactions = $channel->isManageTransactions() && !$db->inTransaction(); + if ($manageTransactions) { + $commited = false; + $db->beginTransaction(); + $commitThreshold = $channel->getEachCommitThreshold(); + } + $count = 0; + $tableName = $channel->getTableName(); + try { + $args ??= []; + $all = $this->_allCached("delete", $channel, $filter); + foreach ($all as $values) { + $rowIds = $this->getRowIds($channel, $values); + $delete = boolval(func::_call($onEach, [$values["item"], $values, ...$args])); + if ($delete) { + $db->exec([ + "delete", + "from" => $tableName, + "where" => $rowIds, + ]); + if ($manageTransactions && $commitThreshold !== null) { + $commitThreshold--; + if ($commitThreshold <= 0) { + $db->commit(); + $db->beginTransaction(); + $commitThreshold = $channel->getEachCommitThreshold(); + } + } + } + $count++; + } + if ($manageTransactions) { + $db->commit(); + $commited = true; + } + return $count; + } finally { + if ($manageTransactions && !$commited) $db->rollback(); + } + } + + function delete(?string $channel, $filter, $func=null, ?array $args=null): int { + return $this->_delete($this->getChannel($channel), $filter, $func, $args); } abstract function close(): void; diff --git a/php/src/db/IDatabase.php b/php/src/db/IDatabase.php new file mode 100644 index 0000000..497e5be --- /dev/null +++ b/php/src/db/IDatabase.php @@ -0,0 +1,19 @@ + &$definition) { + if ($col === $index) { + $index++; + } else { + $definition = "$col $definition"; + } + }; unset($definition); + $sql[] = "(\n ".implode("\n, ", $cols)."\n)"; + + ## suffixe + if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; + + ## fin de la requête + return implode(" ", $sql); + } +} diff --git a/php/src/db/_private/Tdelete.php b/php/src/db/_private/Tdelete.php new file mode 100644 index 0000000..0eeb91a --- /dev/null +++ b/php/src/db/_private/Tdelete.php @@ -0,0 +1,38 @@ + $col) { + if ($key === $index) { + $index++; + $cols[] = $col; + $usercols[] = self::add_prefix($col, $colPrefix); + } else { + $cols[] = $key; + $usercols[] = self::add_prefix($col, $colPrefix)." as $key"; + } + } + } else { + $cols = null; + if ($schema && is_array($schema) && !in_array("*", $usercols)) { + $cols = array_keys($schema); + foreach ($cols as $col) { + $usercols[] = self::add_prefix($col, $colPrefix); + } + } + } + if (!$usercols && !$cols) $usercols = [self::add_prefix("*", $colPrefix)]; + $sql[] = implode(", ", $usercols); + + ## from + $from = $query["from"] ?? null; + if (self::consume('from\s+([a-z_][a-z0-9_]*)\s*(?=;?\s*$|\bwhere\b)', $tmpsql, $ms)) { + if ($from === null) $from = $ms[1]; + $sql[] = "from"; + $sql[] = $from; + } elseif ($from !== null) { + $sql[] = "from"; + $sql[] = $from; + } else { + throw new ValueException("expected table name: $usersql"); + } + + ## where + $userwhere = []; + if (self::consume('where\b\s*(.*?)(?=;?\s*$|\border\s+by\b)', $tmpsql, $ms)) { + if ($ms[1]) $userwhere[] = $ms[1]; + } + $where = cl::withn($query["where"] ?? null); + if ($where !== null) self::parse_conds($where, $userwhere, $bindings); + if ($userwhere) { + $sql[] = "where"; + $sql[] = implode(" and ", $userwhere); + } + + ## order by + $userorderby = []; + if (self::consume('order\s+by\b\s*(.*?)(?=;?\s*$|\bgroup\s+by\b)', $tmpsql, $ms)) { + if ($ms[1]) $userorderby[] = $ms[1]; + } + $orderby = cl::withn($query["order by"] ?? null); + if ($orderby !== null) { + $index = 0; + foreach ($orderby as $key => $value) { + if ($key === $index) { + $userorderby[] = $value; + $index++; + } else { + if ($value === null) $value = false; + if (!is_bool($value)) { + $userorderby[] = "$key $value"; + } elseif ($value) { + $userorderby[] = $key; + } + } + } + } + if ($userorderby) { + $sql[] = "order by"; + $sql[] = implode(", ", $userorderby); + } + ## group by + $usergroupby = []; + if (self::consume('group\s+by\b\s*(.*?)(?=;?\s*$|\bhaving\b)', $tmpsql, $ms)) { + if ($ms[1]) $usergroupby[] = $ms[1]; + } + $groupby = cl::withn($query["group by"] ?? null); + if ($groupby !== null) { + $index = 0; + foreach ($groupby as $key => $value) { + if ($key === $index) { + $usergroupby[] = $value; + $index++; + } else { + if ($value === null) $value = false; + if (!is_bool($value)) { + $usergroupby[] = "$key $value"; + } elseif ($value) { + $usergroupby[] = $key; + } + } + } + } + if ($usergroupby) { + $sql[] = "group by"; + $sql[] = implode(", ", $usergroupby); + } + + ## having + $userhaving = []; + if (self::consume('having\b\s*(.*?)(?=;?\s*$)', $tmpsql, $ms)) { + if ($ms[1]) $userhaving[] = $ms[1]; + } + $having = cl::withn($query["having"] ?? null); + if ($having !== null) self::parse_conds($having, $userhaving, $bindings); + if ($userhaving) { + $sql[] = "having"; + $sql[] = implode(" and ", $userhaving); + } + + ## suffixe + if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; + + ## fin de la requête + self::check_eof($tmpsql, $usersql); + return implode(" ", $sql); + } +} diff --git a/php/src/db/_private/Tupdate.php b/php/src/db/_private/Tupdate.php new file mode 100644 index 0000000..4e1de5b --- /dev/null +++ b/php/src/db/_private/Tupdate.php @@ -0,0 +1,40 @@ + $value) { + if ($key === $index) { + $index++; + if ($sql && !str::ends_with(" ", $sql) && !str::starts_with(" ", $value)) { + $sql .= " "; + } + $sql .= $value; + } + } + return $sql; + } + + protected static function is_sep(&$cond): bool { + if (!is_string($cond)) return false; + if (!preg_match('/^\s*(and|or|not)\s*$/i', $cond, $ms)) return false; + $cond = $ms[1]; + return true; + } + + static function parse_conds(?array $conds, ?array &$sql, ?array &$bindings): void { + if (!$conds) return; + $sep = null; + $index = 0; + $condsql = []; + foreach ($conds as $key => $cond) { + if ($key === $index) { + ## séquentiel + if ($index === 0 && self::is_sep($cond)) { + $sep = $cond; + } elseif (is_bool($cond)) { + # ignorer les valeurs true et false + } elseif (is_array($cond)) { + # condition récursive + self::parse_conds($cond, $condsql, $bindings); + } else { + # condition litérale + $condsql[] = strval($cond); + } + $index++; + } elseif ($cond === false) { + ## associatif + # condition litérale ignorée car condition false + } elseif ($cond === true) { + # condition litérale sélectionnée car condition true + $condsql[] = strval($key); + } else { + ## associatif + # paramètre + $param0 = preg_replace('/^.+\./', "", $key); + $i = false; + if ($bindings !== null && array_key_exists($param0, $bindings)) { + $i = 2; + while (array_key_exists("$param0$i", $bindings)) { + $i++; + } + } + # value ou [operator, value] + $condprefix = $condsep = $condsuffix = null; + if (is_array($cond)) { + $condkey = 0; + $condkeys = array_keys($cond); + $op = null; + if (array_key_exists("op", $cond)) { + $op = $cond["op"]; + } elseif (array_key_exists($condkey, $condkeys)) { + $op = $cond[$condkeys[$condkey]]; + $condkey++; + } + $op = strtolower($op); + $condvalues = null; + switch ($op) { + case "between": + # ["between", $upper, $lower] + $condsep = " and "; + if (array_key_exists("lower", $cond)) { + $condvalues[] = $cond["lower"]; + } elseif (array_key_exists($condkey, $condkeys)) { + $condvalues[] = $cond[$condkeys[$condkey]]; + $condkey++; + } + if (array_key_exists("upper", $cond)) { + $condvalues[] = $cond["upper"]; + } elseif (array_key_exists($condkey, $condkeys)) { + $condvalues[] = $cond[$condkeys[$condkey]]; + $condkey++; + } + break; + case "in": + # ["in", $values] + $condprefix = "("; + $condsep = ", "; + $condsuffix = ")"; + $condvalues = null; + if (array_key_exists("values", $cond)) { + $condvalues = cl::with($cond["values"]); + } elseif (array_key_exists($condkey, $condkeys)) { + $condvalues = cl::with($cond[$condkeys[$condkey]]); + $condkey++; + } + break; + case "null": + case "is null": + $op = "is null"; + break; + case "not null": + case "is not null": + $op = "is not null"; + break; + default: + if (array_key_exists("value", $cond)) { + $condvalues = [$cond["value"]]; + } elseif (array_key_exists($condkey, $condkeys)) { + $condvalues = [$cond[$condkeys[$condkey]]]; + $condkey++; + } + } + } elseif ($cond !== null) { + $op = "="; + $condvalues = [$cond]; + } else { + $op = "is null"; + $condvalues = null; + } + $cond = [$key, $op]; + if ($condvalues !== null) { + $parts = []; + foreach ($condvalues as $condvalue) { + if (is_array($condvalue)) { + $first = true; + foreach ($condvalue as $value) { + if ($first) { + $first = false; + } else { + if ($sep === null) $sep = "and"; + $parts[] = " $sep "; + $parts[] = $key; + $parts[] = " $op "; + } + $param = "$param0$i"; + $parts[] = ":$param"; + $bindings[$param] = $value; + if ($i === false) $i = 2; + else $i++; + } + } else { + $param = "$param0$i"; + $parts[] = ":$param"; + $bindings[$param] = $condvalue; + if ($i === false) $i = 2; + else $i++; + } + } + $cond[] = $condprefix.implode($condsep, $parts).$condsuffix; + } + $condsql[] = implode(" ", $cond); + } + } + if ($sep === null) $sep = "and"; + $count = count($condsql); + if ($count > 1) { + $sql[] = "(" . implode(" $sep ", $condsql) . ")"; + } elseif ($count == 1) { + $sql[] = $condsql[0]; + } + } + + static function parse_set_values(?array $values, ?array &$sql, ?array &$bindings): void { + if (!$values) return; + $index = 0; + $parts = []; + foreach ($values as $key => $part) { + if ($key === $index) { + ## séquentiel + if (is_array($part)) { + # paramètres récursifs + self::parse_set_values($part, $parts, $bindings); + } else { + # paramètre litéral + $parts[] = strval($part); + } + $index++; + } else { + ## associatif + # paramètre + $param = $param0 = preg_replace('/^.+\./', "", $key); + if ($bindings !== null && array_key_exists($param0, $bindings)) { + $i = 2; + while (array_key_exists("$param0$i", $bindings)) { + $i++; + } + $param = "$param0$i"; + } + # value + $value = $part; + $part = [$key, "="]; + if ($value === null) { + $part[] = "null"; + } else { + $part[] = ":$param"; + $bindings[$param] = $value; + } + $parts[] = implode(" ", $part); + } + } + $sql = cl::merge($sql, $parts); + } + + protected static function check_eof(string $tmpsql, string $usersql): void { + self::consume(';\s*', $tmpsql); + if ($tmpsql) { + throw new ValueException("unexpected value at end: $usersql"); + } + } + + abstract protected static function verifix(&$sql, ?array &$bindinds=null, ?array &$meta=null): void; + + function __construct($sql, ?array $bindings=null) { + static::verifix($sql, $bindings, $meta); + $this->sql = $sql; + $this->bindings = $bindings; + $this->meta = $meta; + } + + /** @var string */ + protected $sql; + + function getSql(): string { + return $this->sql; + } + + /** @var ?array */ + protected $bindings; + + function getBindings(): ?array { + return $this->bindings; + } + + /** @var ?array */ + protected $meta; + + function isInsert(): bool { + return ($this->meta["isa"] ?? null) === "insert"; + } +} diff --git a/php/src/db/_private/_create.php b/php/src/db/_private/_create.php new file mode 100644 index 0000000..64c29a5 --- /dev/null +++ b/php/src/db/_private/_create.php @@ -0,0 +1,12 @@ + "?string", + "table" => "string", + "schema" => "?array", + "cols" => "?array", + "suffix" => "?string", + ]; +} diff --git a/php/src/db/_private/_delete.php b/php/src/db/_private/_delete.php new file mode 100644 index 0000000..e79ec34 --- /dev/null +++ b/php/src/db/_private/_delete.php @@ -0,0 +1,11 @@ + "?string", + "from" => "?string", + "where" => "?array", + "suffix" => "?string", + ]; +} diff --git a/php/src/db/_private/_generic.php b/php/src/db/_private/_generic.php new file mode 100644 index 0000000..97d4b51 --- /dev/null +++ b/php/src/db/_private/_generic.php @@ -0,0 +1,7 @@ + "?string", + "into" => "?string", + "schema" => "?array", + "cols" => "?array", + "values" => "?array", + "suffix" => "?string", + ]; +} diff --git a/php/src/db/_private/_select.php b/php/src/db/_private/_select.php new file mode 100644 index 0000000..ee2bdbc --- /dev/null +++ b/php/src/db/_private/_select.php @@ -0,0 +1,17 @@ + "?string", + "schema" => "?array", + "cols" => "?array", + "col_prefix" => "?string", + "from" => "?string", + "where" => "?array", + "order by" => "?array", + "group by" => "?array", + "having" => "?array", + "suffix" => "?string", + ]; +} diff --git a/php/src/db/_private/_update.php b/php/src/db/_private/_update.php new file mode 100644 index 0000000..b5b2dc6 --- /dev/null +++ b/php/src/db/_private/_update.php @@ -0,0 +1,14 @@ + "?string", + "table" => "?string", + "schema" => "?array", + "cols" => "?array", + "values" => "?array", + "where" => "?array", + "suffix" => "?string", + ]; +} diff --git a/php/src/db/cache/CacheChannel.php b/php/src/db/cache/CacheChannel.php new file mode 100644 index 0000000..b1f8619 --- /dev/null +++ b/php/src/db/cache/CacheChannel.php @@ -0,0 +1,116 @@ + "varchar(64) not null", + "id" => "varchar(64) not null", + "date_start" => "datetime", + "duration_" => "text", + "primary key (group_id, id)", + ]; + + static function get_cache_ids($id): array { + if (is_array($id)) { + $keys = array_keys($id); + if (array_key_exists("group_id", $id)) $groupIdKey = "group_id"; + else $groupIdKey = $keys[1] ?? null; + $groupId = $id[$groupIdKey] ?? ""; + if (array_key_exists("id", $id)) $idKey = "id"; + else $idKey = $keys[0] ?? null; + $id = $id[$idKey] ?? ""; + } else { + $groupId = ""; + } + if (preg_match('/^(.*\\\\)?([^\\\\]+)$/', $groupId, $ms)) { + # si le groupe est une classe, faire un hash du package pour limiter la + # longueur du groupe + [$package, $groupId] = [$ms[1], $ms[2]]; + $package = substr(md5($package), 0, 4); + $groupId = "${groupId}_$package"; + } + return ["group_id" => $groupId, "id" => $id]; + } + + function __construct(?string $duration=null, ?string $name=null) { + parent::__construct($name); + $this->duration = $duration ?? static::DURATION; + $this->includes = static::INCLUDES; + $this->excludes = static::EXCLUDES; + } + + protected string $duration; + + protected ?array $includes; + + protected ?array $excludes; + + function getItemValues($item): ?array { + return cl::merge(self::get_cache_ids($item), [ + "item" => null, + ]); + } + + function onCreate($item, array $values, ?array $alwaysNull, ?string $duration=null): ?array { + $now = new DateTime(); + $duration ??= $this->duration; + return [ + "date_start" => $now, + "duration" => new Delay($duration, $now), + ]; + } + + function onUpdate($item, array $values, array $pvalues, ?string $duration=null): ?array { + $now = new DateTime(); + $duration ??= $this->duration; + return [ + "date_start" => $now, + "duration" => new Delay($duration, $now), + ]; + } + + function shouldUpdate($id, bool $noCache=false): bool { + if ($noCache) return true; + + $cacheIds = self::get_cache_ids($id); + $groupId = $cacheIds["group_id"]; + if ($groupId) { + $includes = $this->includes; + $shouldInclude = $includes !== null && in_array($groupId, $includes); + $excludes = $this->excludes; + $shouldExclude = $excludes !== null && in_array($groupId, $excludes); + if (!$shouldInclude || $shouldExclude) return true; + } + + $found = false; + $expired = false; + $this->each($cacheIds, + function($item, $values) use (&$found, &$expired) { + $found = true; + $expired = $values["duration"]->isElapsed(); + }); + return !$found || $expired; + } + + function setCached($id, ?string $duration=null): void { + $cacheIds = self::get_cache_ids($id); + $this->charge($cacheIds, null, [$duration]); + } + + function resetCached($id) { + $cacheIds = self::get_cache_ids($id); + $this->delete($cacheIds); + } +} diff --git a/php/src/db/cache/RowsChannel.php b/php/src/db/cache/RowsChannel.php new file mode 100644 index 0000000..a3f7055 --- /dev/null +++ b/php/src/db/cache/RowsChannel.php @@ -0,0 +1,51 @@ + "varchar(128) primary key not null", + "all_values" => "mediumtext", + ]; + + function __construct($id, callable $builder, ?string $duration=null) { + $this->cacheIds = $cacheIds = CacheChannel::get_cache_ids($id); + $this->builder = Closure::fromCallable($builder); + $this->duration = $duration; + $name = "{$cacheIds["group_id"]}-{$cacheIds["id"]}"; + parent::__construct($name); + } + + protected array $cacheIds; + + protected Closure $builder; + + protected ?string $duration = null; + + function getItemValues($item): ?array { + $key = array_keys($item)[0]; + $row = $item[$key]; + return [ + "key" => $key, + "item" => $row, + "all_values" => implode(" ", cl::filter_n(cl::with($row))), + ]; + } + + function getIterator(): Traversable { + $cm = cache::get(); + if ($cm->shouldUpdate($this->cacheIds)) { + $this->capacitor->reset(); + foreach (($this->builder)() as $key => $row) { + $this->charge([$key => $row]); + } + $cm->setCached($this->cacheIds, $this->duration); + } + return $this->discharge(false); + } +} diff --git a/php/src/db/cache/cache.php b/php/src/db/cache/cache.php new file mode 100644 index 0000000..401fb19 --- /dev/null +++ b/php/src/db/cache/cache.php @@ -0,0 +1,37 @@ +dbconn["name"] ?? null; + if ($url !== null && preg_match('/^mysql(?::|.*;)dbname=([^;]+)/i', $url, $ms)) { + return $ms[1]; + } + return null; + } +} diff --git a/php/src/db/mysql/MysqlStorage.php b/php/src/db/mysql/MysqlStorage.php new file mode 100644 index 0000000..50b09d2 --- /dev/null +++ b/php/src/db/mysql/MysqlStorage.php @@ -0,0 +1,46 @@ +db = Mysql::with($mysql); + } + + /** @var Mysql */ + protected $db; + + function db(): Mysql { + return $this->db; + } + + const PRIMARY_KEY_DEFINITION = [ + "id_" => "integer primary key auto_increment", + ]; + + function _getCreateSql(CapacitorChannel $channel): string { + $query = new _query_base($this->_createSql($channel)); + return self::format_sql($channel, $query->getSql()); + } + + function _exists(CapacitorChannel $channel): bool { + $db = $this->db; + $tableName = $db->get([ + "select table_name from information_schema.tables", + "where" => [ + "table_schema" => $db->getDbname(), + "table_name" => $channel->getTableName(), + ], + ]); + return $tableName !== null; + } + + function close(): void { + $this->db->close(); + } +} diff --git a/php/src/db/mysql/_query_base.php b/php/src/db/mysql/_query_base.php new file mode 100644 index 0000000..614ec06 --- /dev/null +++ b/php/src/db/mysql/_query_base.php @@ -0,0 +1,52 @@ + "create", "type" => "ddl"]; + } elseif (_query_select::isa($prefix)) { + $sql = _query_select::parse($sql, $bindinds); + $meta = ["isa" => "select", "type" => "dql"]; + } elseif (_query_insert::isa($prefix)) { + $sql = _query_insert::parse($sql, $bindinds); + $meta = ["isa" => "insert", "type" => "dml"]; + } elseif (_query_update::isa($prefix)) { + $sql = _query_update::parse($sql, $bindinds); + $meta = ["isa" => "update", "type" => "dml"]; + } elseif (_query_delete::isa($prefix)) { + $sql = _query_delete::parse($sql, $bindinds); + $meta = ["isa" => "delete", "type" => "dml"]; + } elseif (_query_generic::isa($prefix)) { + $sql = _query_generic::parse($sql, $bindinds); + $meta = ["isa" => "generic", "type" => null]; + } else { + throw ValueException::invalid_kind($sql, "query"); + } + } else { + if (!is_string($sql)) $sql = strval($sql); + if (_query_create::isa($sql)) { + $meta = ["isa" => "create", "type" => "ddl"]; + } elseif (_query_select::isa($sql)) { + $meta = ["isa" => "select", "type" => "dql"]; + } elseif (_query_insert::isa($sql)) { + $meta = ["isa" => "insert", "type" => "dml"]; + } elseif (_query_update::isa($sql)) { + $meta = ["isa" => "update", "type" => "dml"]; + } elseif (_query_delete::isa($sql)) { + $meta = ["isa" => "delete", "type" => "dml"]; + } elseif (_query_generic::isa($sql)) { + $meta = ["isa" => "generic", "type" => null]; + } else { + $meta = ["isa" => "generic", "type" => null]; + } + } + } +} diff --git a/php/src/db/mysql/_query_create.php b/php/src/db/mysql/_query_create.php new file mode 100644 index 0000000..11f6602 --- /dev/null +++ b/php/src/db/mysql/_query_create.php @@ -0,0 +1,10 @@ + $pdo->dbconn, + "options" => $pdo->options, + "config" => $pdo->config, + "migrate" => $pdo->migration, + ], $params)); + } else { + return new static($pdo, $params); + } + } + + static function config_errmodeException_lowerCase(self $pdo): void { + $pdo->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $pdo->db->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_LOWER); + } + const CONFIG_errmodeException_lowerCase = [self::class, "config_errmodeException_lowerCase"]; + + static function config_unbufferedQueries(self $pdo): void { + $pdo->db->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); + } + const CONFIG_unbufferedQueries = [self::class, "config_unbufferedQueries"]; + + protected const OPTIONS = [ + \PDO::ATTR_PERSISTENT => true, + ]; + + protected const DEFAULT_CONFIG = [ + self::CONFIG_errmodeException_lowerCase, + ]; + + protected const CONFIG = null; + + protected const MIGRATE = null; + + const dbconn_SCHEMA = [ + "name" => "string", + "user" => "?string", + "pass" => "?string", + ]; + + const params_SCHEMA = [ + "dbconn" => ["array"], + "options" => ["?array|callable"], + "replace_config" => ["?array|callable"], + "config" => ["?array|callable"], + "migrate" => ["?array|string|callable"], + "auto_open" => ["bool", true], + ]; + + function __construct($dbconn=null, ?array $params=null) { + if ($dbconn !== null) { + if (!is_array($dbconn)) { + $dbconn = ["name" => $dbconn]; + #XXX à terme, il faudra interroger config + #$tmp = config::db($dbconn); + #if ($tmp !== null) $dbconn = $tmp; + #else $dbconn = ["name" => $dbconn]; + } + $params["dbconn"] = $dbconn; + } + # dbconn + $this->dbconn = $params["dbconn"] ?? null; + $this->dbconn["name"] ??= null; + $this->dbconn["user"] ??= null; + $this->dbconn["pass"] ??= null; + # options + $this->options = $params["options"] ?? static::OPTIONS; + # configuration + $config = $params["replace_config"] ?? null; + if ($config === null) { + $config = $params["config"] ?? static::CONFIG; + if (is_callable($config)) $config = [$config]; + $config = cl::merge(static::DEFAULT_CONFIG, $config); + } + $this->config = $config; + # migrations + $this->migration = $params["migrate"] ?? static::MIGRATE; + # + $defaultAutoOpen = self::params_SCHEMA["auto_open"][1]; + if ($params["auto_open"] ?? $defaultAutoOpen) { + $this->open(); + } + } + + protected ?array $dbconn; + + /** @var array|callable */ + protected array $options; + + /** @var array|string|callable */ + protected $config; + + /** @var array|string|callable */ + protected $migration; + + protected ?\PDO $db = null; + + function open(): self { + if ($this->db === null) { + $dbconn = $this->dbconn; + $options = $this->options; + if (is_callable($options)) { + func::ensure_func($options, $this, $args); + $options = func::call($options, ...$args); + } + $this->db = new \PDO($dbconn["name"], $dbconn["user"], $dbconn["pass"], $options); + _config::with($this->config)->configure($this); + //_migration::with($this->migration)->migrate($this); + } + return $this; + } + + function close(): void { + $this->db = null; + } + + protected function db(): \PDO { + $this->open(); + return $this->db; + } + + /** @return int|false */ + function _exec(string $query) { + return $this->db()->exec($query); + } + + private static function is_insert(?string $sql): bool { + if ($sql === null) return false; + return preg_match('/^\s*insert\b/i', $sql); + } + + function exec($query, ?array $params=null) { + $db = $this->db(); + $query = new _query_base($query, $params); + if ($query->useStmt($db, $stmt, $sql)) { + if ($stmt->execute() === false) return false; + if ($query->isInsert()) return $db->lastInsertId(); + else return $stmt->rowCount(); + } else { + $rowCount = $db->exec($sql); + if (self::is_insert($sql)) return $db->lastInsertId(); + else return $rowCount; + } + } + + /** @var ITransactor[] */ + protected ?array $transactors = null; + + function willUpdate(...$transactors): self { + foreach ($transactors as $transactor) { + if ($transactor instanceof ITransactor) { + $this->transactors[] = $transactor; + $transactor->willUpdate(); + } else { + throw ValueException::invalid_type($transactor, ITransactor::class); + } + } + return $this; + } + + function inTransaction(): bool { + return $this->db()->inTransaction(); + } + + function beginTransaction(?callable $func=null, bool $commit=true): void { + $this->db()->beginTransaction(); + if ($this->transactors !== null) { + foreach ($this->transactors as $transactor) { + $transactor->beginTransaction(); + } + } + if ($func !== null) { + $commited = false; + try { + func::call($func, $this); + if ($commit) { + $this->commit(); + $commited = true; + } + } finally { + if ($commit && !$commited) $this->rollback(); + } + } + } + + function commit(): void { + $this->db()->commit(); + if ($this->transactors !== null) { + foreach ($this->transactors as $transactor) { + $transactor->commit(); + } + } + } + + function rollback(): void { + $this->db()->rollBack(); + if ($this->transactors !== null) { + foreach ($this->transactors as $transactor) { + $transactor->rollback(); + } + } + } + + /** + * Tester si $date est une date/heure valide de la forme "YYYY-mm-dd HH:MM:SS" + * + * Si oui, $ms obtient les 6 éléments de la chaine + */ + static function is_datetime($date, ?array &$ms=null): bool { + return is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $date, $ms); + } + + /** + * Tester si $date est une date valide de la forme "YYYY-mm-dd [00:00:00]" + * + * Si oui, $ms obtient les 3 éléments de la chaine + */ + static function is_date($date, ?array &$ms=null): bool { + return is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})(?: 00:00:00)?$/', $date, $ms); + } + + function verifixRow(array &$row) { + foreach ($row as &$value) { + if (self::is_date($value)) { + $value = new Date($value); + } elseif (self::is_datetime($value)) { + $value = new DateTime($value); + } + }; unset($value); + } + + function get($query, ?array $params=null, bool $entireRow=false) { + $db = $this->db(); + $query = new _query_base($query, $params); + $stmt = null; + try { + /** @var \PDOStatement $stmt */ + if ($query->useStmt($db, $stmt, $sql)) { + if ($stmt->execute() === false) return null; + } else { + $stmt = $db->query($sql); + } + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + if ($row === false) return null; + $this->verifixRow($row); + if ($entireRow) return $row; + else return cl::first($row); + } finally { + if ($stmt instanceof \PDOStatement) $stmt->closeCursor(); + } + } + + function one($query, ?array $params=null): ?array { + return $this->get($query, $params, true); + } + + /** + * si $primaryKeys est fourni, le résultat est indexé sur la(es) colonne(s) + * spécifiée(s) + */ + function all($query, ?array $params=null, $primaryKeys=null): Generator { + $db = $this->db(); + $query = new _query_base($query, $params); + $stmt = null; + try { + /** @var \PDOStatement $stmt */ + if ($query->useStmt($db, $stmt, $sql)) { + if ($stmt->execute() === false) return; + } else { + $stmt = $db->query($sql); + } + if ($primaryKeys !== null) $primaryKeys = cl::with($primaryKeys); + while (($row = $stmt->fetch(\PDO::FETCH_ASSOC)) !== false) { + $this->verifixRow($row); + if ($primaryKeys !== null) { + $key = implode("-", cl::select($row, $primaryKeys)); + yield $key => $row; + } else { + yield $row; + } + } + } finally { + if ($stmt instanceof \PDOStatement) $stmt->closeCursor(); + } + } +} diff --git a/php/src/db/pdo/_config.php b/php/src/db/pdo/_config.php new file mode 100644 index 0000000..8b14b8f --- /dev/null +++ b/php/src/db/pdo/_config.php @@ -0,0 +1,36 @@ +configs = $configs; + } + + /** @var array */ + protected $configs; + + function configure(Pdo $pdo): void { + foreach ($this->configs as $key => $config) { + if (is_string($config) && !func::is_method($config)) { + $pdo->exec($config); + } else { + func::ensure_func($config, $this, $args); + func::call($config, $pdo, $key, ...$args); + } + } + } +} diff --git a/php/src/db/pdo/_query_base.php b/php/src/db/pdo/_query_base.php new file mode 100644 index 0000000..be7cefb --- /dev/null +++ b/php/src/db/pdo/_query_base.php @@ -0,0 +1,104 @@ + "create", "type" => "ddl"]; + } elseif (_query_select::isa($prefix)) { + $sql = _query_select::parse($sql, $bindinds); + $meta = ["isa" => "select", "type" => "dql"]; + } elseif (_query_insert::isa($prefix)) { + $sql = _query_insert::parse($sql, $bindinds); + $meta = ["isa" => "insert", "type" => "dml"]; + } elseif (_query_update::isa($prefix)) { + $sql = _query_update::parse($sql, $bindinds); + $meta = ["isa" => "update", "type" => "dml"]; + } elseif (_query_delete::isa($prefix)) { + $sql = _query_delete::parse($sql, $bindinds); + $meta = ["isa" => "delete", "type" => "dml"]; + } elseif (_query_generic::isa($prefix)) { + $sql = _query_generic::parse($sql, $bindinds); + $meta = ["isa" => "generic", "type" => null]; + } else { + throw ValueException::invalid_kind($sql, "query"); + } + } else { + if (!is_string($sql)) $sql = strval($sql); + if (_query_create::isa($sql)) { + $meta = ["isa" => "create", "type" => "ddl"]; + } elseif (_query_select::isa($sql)) { + $meta = ["isa" => "select", "type" => "dql"]; + } elseif (_query_insert::isa($sql)) { + $meta = ["isa" => "insert", "type" => "dml"]; + } elseif (_query_update::isa($sql)) { + $meta = ["isa" => "update", "type" => "dml"]; + } elseif (_query_delete::isa($sql)) { + $meta = ["isa" => "delete", "type" => "dml"]; + } elseif (_query_generic::isa($sql)) { + $meta = ["isa" => "generic", "type" => null]; + } else { + $meta = ["isa" => "generic", "type" => null]; + } + } + } + + static function is_sqldate(string $date): bool { + return preg_match('/^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?$/', $date); + } + + protected function verifixBindings(&$value): void { + if ($value instanceof Date) { + $value = $value->format('Y-m-d'); + } elseif ($value instanceof DateTime) { + $value = $value->format('Y-m-d H:i:s'); + } elseif ($value instanceof DateTimeInterface) { + $value = $value->format('Y-m-d H:i:s'); + str::del_suffix($value, " 00:00:00"); + } elseif (is_string($value)) { + if (self::is_sqldate($value)) { + # déjà dans le bon format + } elseif (Date::isa_date($value, true)) { + $value = new Date($value); + $value = $value->format('Y-m-d'); + } elseif (DateTime::isa_datetime($value, true)) { + $value = new DateTime($value); + $value = $value->format('Y-m-d H:i:s'); + } + } elseif (is_bool($value)) { + $value = $value? 1: 0; + } + } + + const DEBUG_QUERIES = false; + + function useStmt(\PDO $db, ?\PDOStatement &$stmt=null, ?string &$sql=null): bool { + if (static::DEBUG_QUERIES) { #XXX + error_log($this->sql); + //error_log(var_export($this->bindings, true)); + } + if ($this->bindings !== null) { + $stmt = $db->prepare($this->sql); + foreach ($this->bindings as $name => $value) { + $this->verifixBindings($value); + $stmt->bindValue($name, $value); + } + return true; + } else { + $sql = $this->sql; + return false; + } + } +} diff --git a/php/src/db/pdo/_query_create.php b/php/src/db/pdo/_query_create.php new file mode 100644 index 0000000..997349a --- /dev/null +++ b/php/src/db/pdo/_query_create.php @@ -0,0 +1,10 @@ +db->enableExceptions(true); } + const CONFIG_enableExceptions = [self::class, "config_enableExceptions"]; + + /** + * @var int temps maximum à attendre que la base soit accessible si elle est + * verrouillée + */ + protected const BUSY_TIMEOUT = 30 * 1000; + + static function config_busyTimeout(self $sqlite): void { + $sqlite->db->busyTimeout(static::BUSY_TIMEOUT); + } + const CONFIG_busyTimeout = [self::class, "config_busyTimeout"]; static function config_enableWalIfAllowed(self $sqlite): void { if ($sqlite->isWalAllowed()) { $sqlite->db->exec("PRAGMA journal_mode=WAL"); } } + const CONFIG_enableWalIfAllowed = [self::class, "config_enableWalIfAllowed"]; const ALLOW_WAL = null; - const CONFIG = [ - [self::class, "config_enableExceptions"], - [self::class, "config_enableWalIfAllowed"], + const DEFAULT_CONFIG = [ + self::CONFIG_enableExceptions, + self::CONFIG_busyTimeout, + self::CONFIG_enableWalIfAllowed, ]; + const CONFIG = null; + const MIGRATE = null; - const SCHEMA = [ + const params_SCHEMA = [ "file" => ["string", ""], "flags" => ["int", SQLITE3_OPEN_READWRITE + SQLITE3_OPEN_CREATE], "encryption_key" => ["string", ""], "allow_wal" => ["?bool"], + "replace_config" => ["?array|callable"], "config" => ["?array|callable"], "migrate" => ["?array|string|callable"], "auto_open" => ["bool", true], @@ -63,24 +84,31 @@ class Sqlite { function __construct(?string $file=null, ?array $params=null) { if ($file !== null) $params["file"] = $file; ##schéma - $defaultFile = self::SCHEMA["file"][1]; + $defaultFile = self::params_SCHEMA["file"][1]; $this->file = $file = strval($params["file"] ?? $defaultFile); - $inMemory = $file === ":memory:"; + $inMemory = $file === ":memory:" || $file === ""; # - $defaultFlags = self::SCHEMA["flags"][1]; + $defaultFlags = self::params_SCHEMA["flags"][1]; $this->flags = intval($params["flags"] ?? $defaultFlags); # - $defaultEncryptionKey = self::SCHEMA["encryption_key"][1]; + $defaultEncryptionKey = self::params_SCHEMA["encryption_key"][1]; $this->encryptionKey = strval($params["encryption_key"] ?? $defaultEncryptionKey); # $defaultAllowWal = static::ALLOW_WAL ?? !$inMemory; $this->allowWal = $params["allow_wal"] ?? $defaultAllowWal; # configuration - $this->config = $params["config"] ?? static::CONFIG; + $config = $params["replace_config"] ?? null; + if ($config === null) { + $config = $params["config"] ?? static::CONFIG; + if (is_callable($config)) $config = [$config]; + $config = cl::merge(static::DEFAULT_CONFIG, $config); + } + $this->config = $config; # migrations $this->migration = $params["migrate"] ?? static::MIGRATE; # - $defaultAutoOpen = self::SCHEMA["auto_open"][1]; + $defaultAutoOpen = self::params_SCHEMA["auto_open"][1]; + $this->inTransaction = false; if ($params["auto_open"] ?? $defaultAutoOpen) { $this->open(); } @@ -112,11 +140,14 @@ class Sqlite { /** @var SQLite3 */ protected $db; + protected bool $inTransaction; + function open(): self { if ($this->db === null) { $this->db = new SQLite3($this->file, $this->flags, $this->encryptionKey); _config::with($this->config)->configure($this); _migration::with($this->migration)->migrate($this); + $this->inTransaction = false; } return $this; } @@ -125,6 +156,7 @@ class Sqlite { if ($this->db !== null) { $this->db->close(); $this->db = null; + $this->inTransaction = false; } } @@ -145,30 +177,92 @@ class Sqlite { return $this->db()->exec($query); } - function exec($query, ?array $params=null): bool { + private static function is_insert(?string $sql): bool { + if ($sql === null) return false; + return preg_match('/^\s*insert\b/i', $sql); + } + + function exec($query, ?array $params=null) { $db = $this->db(); - $query = new _query($query, $params); + $query = new _query_base($query, $params); if ($query->useStmt($db, $stmt, $sql)) { try { - return $stmt->execute()->finalize(); + $result = $stmt->execute(); + if ($result === false) return false; + $result->finalize(); + if ($query->isInsert()) return $db->lastInsertRowID(); + else return $db->changes(); } finally { $stmt->close(); } } else { - return $db->exec($sql); + $result = $db->exec($sql); + if ($result === false) return false; + if (self::is_insert($sql)) return $db->lastInsertRowID(); + else return $db->changes(); } } - function beginTransaction(): void { + /** @var ITransactor[] */ + protected ?array $transactors = null; + + function willUpdate(...$transactors): self { + foreach ($transactors as $transactor) { + if ($transactor instanceof ITransactor) { + $this->transactors[] = $transactor; + $transactor->willUpdate(); + } else { + throw ValueException::invalid_type($transactor, ITransactor::class); + } + } + return $this; + } + + function inTransaction(): bool { + #XXX très imparfait, mais y'a rien de mieux pour le moment :-( + return $this->inTransaction; + } + + function beginTransaction(?callable $func=null, bool $commit=true): void { $this->db()->exec("begin"); + $this->inTransaction = true; + if ($this->transactors !== null) { + foreach ($this->transactors as $transactor) { + $transactor->beginTransaction(); + } + } + if ($func !== null) { + $commited = false; + try { + func::call($func, $this); + if ($commit) { + $this->commit(); + $commited = true; + } + } finally { + if ($commit && !$commited) $this->rollback(); + } + } } function commit(): void { + $this->inTransaction = false; $this->db()->exec("commit"); + if ($this->transactors !== null) { + foreach ($this->transactors as $transactor) { + $transactor->commit(); + } + } } function rollback(): void { - $this->db()->exec("commit"); + $this->inTransaction = false; + $this->db()->exec("rollback"); + if ($this->transactors !== null) { + foreach ($this->transactors as $transactor) { + $transactor->rollback(); + } + } } function _get(string $query, bool $entireRow=false) { @@ -177,7 +271,7 @@ class Sqlite { function get($query, ?array $params=null, bool $entireRow=false) { $db = $this->db(); - $query = new _query($query, $params); + $query = new _query_base($query, $params); if ($query->useStmt($db, $stmt, $sql)) { try { $result = $this->checkResult($stmt->execute()); @@ -201,10 +295,16 @@ class Sqlite { return $this->get($query, $params, true); } - protected function _fetchResult(SQLite3Result $result, ?SQLite3Stmt $stmt=null): Generator { + protected function _fetchResult(SQLite3Result $result, ?SQLite3Stmt $stmt=null, $primaryKeys=null): Generator { + if ($primaryKeys !== null) $primaryKeys = cl::with($primaryKeys); try { while (($row = $result->fetchArray(SQLITE3_ASSOC)) !== false) { - yield $row; + if ($primaryKeys !== null) { + $key = implode("-", cl::select($row, $primaryKeys)); + yield $key => $row; + } else { + yield $row; + } } } finally { $result->finalize(); @@ -212,15 +312,19 @@ class Sqlite { } } - function all($query, ?array $params=null): iterable { + /** + * si $primaryKeys est fourni, le résultat est indexé sur la(es) colonne(s) + * spécifiée(s) + */ + function all($query, ?array $params=null, $primaryKeys=null): iterable { $db = $this->db(); - $query = new _query($query, $params); + $query = new _query_base($query, $params); if ($query->useStmt($db, $stmt, $sql)) { $result = $this->checkResult($stmt->execute()); - return $this->_fetchResult($result, $stmt); + return $this->_fetchResult($result, $stmt, $primaryKeys); } else { $result = $this->checkResult($db->query($sql)); - return $this->_fetchResult($result); + return $this->_fetchResult($result, null, $primaryKeys); } } } diff --git a/php/src/db/sqlite/SqliteStorage.php b/php/src/db/sqlite/SqliteStorage.php index ca23c88..820256f 100644 --- a/php/src/db/sqlite/SqliteStorage.php +++ b/php/src/db/sqlite/SqliteStorage.php @@ -1,116 +1,35 @@ sqlite = Sqlite::with($sqlite); + $this->db = Sqlite::with($sqlite); } /** @var Sqlite */ - protected $sqlite; + protected $db; - function sqlite(): Sqlite { - return $this->sqlite; + function db(): Sqlite { + return $this->db; } - const KEY_DEFINITIONS = [ + const PRIMARY_KEY_DEFINITION = [ "id_" => "integer primary key autoincrement", - "item__" => "text", - "sum_" => "varchar(40)", - "created_" => "datetime", - "modified_" => "datetime", ]; - /** sérialiser les valeurs qui doivent l'être dans $values */ - protected function serialize(CapacitorChannel $channel, ?array $values): ?array { - if ($values === null) return null; - $columns = cl::merge(self::KEY_DEFINITIONS, $channel->getKeyDefinitions()); - $index = 0; - $row = []; - foreach (array_keys($columns) as $column) { - $key = $column; - if ($key === $index) { - $index++; - continue; - } elseif (str::del_suffix($key, "__")) { - if (!array_key_exists($key, $values)) continue; - $value = $values[$key]; - if ($value !== null) $value = serialize($value); - } else { - if (!array_key_exists($key, $values)) continue; - $value = $values[$key]; - } - $row[$column] = $value; - } - return $row; - } - - /** désérialiser les valeurs qui doivent l'être dans $values */ - protected function unserialize(CapacitorChannel $channel, ?array $row): ?array { - if ($row === null) return null; - $columns = cl::merge(self::KEY_DEFINITIONS, $channel->getKeyDefinitions()); - $index = 0; - $values = []; - foreach (array_keys($columns) as $column) { - $key = $column; - if ($key === $index) { - $index++; - continue; - } elseif (!array_key_exists($column, $row)) { - continue; - } elseif (str::del_suffix($key, "__")) { - $value = $row[$column]; - if ($value !== null) $value = unserialize($value); - } else { - $value = $row[$column]; - } - $values[$key] = $value; - } - return $values; - } - - protected function _create(CapacitorChannel $channel): void { - if (!$channel->isCreated()) { - $columns = cl::merge(self::KEY_DEFINITIONS, $channel->getKeyDefinitions()); - $this->sqlite->exec([ - "create table if not exists", - "table" => $channel->getTableName(), - "cols" => $columns, - ]); - $channel->setCreated(); - } - } - - /** @var CapacitorChannel[] */ - protected $channels; - - function addChannel(CapacitorChannel $channel): CapacitorChannel { - $this->_create($channel); - $this->channels[$channel->getName()] = $channel; - return $channel; - } - - protected function getChannel(?string $name): CapacitorChannel { - $name = CapacitorChannel::verifix_name($name); - $channel = $this->channels[$name] ?? null; - if ($channel === null) { - $channel = $this->addChannel(new CapacitorChannel($name)); - } - return $channel; + function _getCreateSql(CapacitorChannel $channel): string { + $query = new _query_base($this->_createSql($channel)); + return self::format_sql($channel, $query->getSql()); } function _exists(CapacitorChannel $channel): bool { - $tableName = $this->sqlite->get([ + $tableName = $this->db->get([ "select name from sqlite_schema", "where" => [ "name" => $channel->getTableName(), @@ -119,191 +38,7 @@ class SqliteStorage extends CapacitorStorage { return $tableName !== null; } - function _ensureExists(CapacitorChannel $channel): void { - $this->_create($channel); - } - - function _reset(CapacitorChannel $channel): void { - $this->sqlite->exec([ - "drop table if exists", - $channel->getTableName(), - ]); - $channel->setCreated(false); - } - - function _charge(CapacitorChannel $channel, $item, ?callable $func, ?array $args): int { - $this->_create($channel); - $now = date("Y-m-d H:i:s"); - $item__ = serialize($item); - $sum_ = sha1($item__); - $row = cl::merge([ - "item__" => $item__, - "sum_" => $sum_, - ], $this->unserialize($channel, $channel->getKeyValues($item))); - $prow = null; - $id_ = $row["id_"] ?? null; - if ($id_ !== null) { - # modification - $prow = $this->sqlite->one([ - "select id_, item__, sum_, created_, modified_", - "from" => $channel->getTableName(), - "where" => ["id_" => $id_], - ]); - } - $insert = null; - if ($prow === null) { - # création - $row = cl::merge($row, [ - "created_" => $now, - "modified_" => $now, - ]); - $insert = true; - } elseif ($sum_ !== $prow["sum_"]) { - # modification - $row = cl::merge($row, [ - "modified_" => $now, - ]); - $insert = false; - } - - if ($func === null) $func = [$channel, "onCharge"]; - $onCharge = func::_prepare($func); - $args ??= []; - $values = $this->unserialize($channel, $row); - $pvalues = $this->unserialize($channel, $prow); - $updates = func::_call($onCharge, [$item, $values, $pvalues, ...$args]); - if (is_array($updates)) { - $updates = $this->serialize($channel, $updates); - if (array_key_exists("item__", $updates)) { - # si item a été mis à jour, il faut mettre à jour sum_ - $updates["sum_"] = sha1($updates["item__"]); - if (!array_key_exists("modified_", $updates)) { - $updates["modified_"] = $now; - } - } - $row = cl::merge($row, $updates); - } - - if ($insert === null) { - # aucune modification - return 0; - } elseif ($insert) { - $this->sqlite->exec([ - "insert", - "into" => $channel->getTableName(), - "values" => $row, - ]); - } else { - $this->sqlite->exec([ - "update", - "table" => $channel->getTableName(), - "values" => $row, - "where" => ["id_" => $id_], - ]); - } - return 1; - } - - function _discharge(CapacitorChannel $channel, bool $reset=true): iterable { - $rows = $this->sqlite->all([ - "select item__", - "from" => $channel->getTableName(), - ]); - foreach ($rows as $row) { - yield unserialize($row['item__']); - } - if ($reset) $this->_reset($channel); - } - - protected function verifixFilter(CapacitorChannel $channel, &$filter): void { - if ($filter !== null && !is_array($filter)) { - $id = $filter; - $channel->verifixId($id); - $filter = ["id_" => $id]; - } - $filter = $this->serialize($channel, $filter); - } - - function _count(CapacitorChannel $channel, $filter): int { - $this->verifixFilter($channel, $filter); - return $this->sqlite->get([ - "select count(*)", - "from" => $channel->getTableName(), - "where" => $filter, - ]); - } - - function _one(CapacitorChannel $channel, $filter): ?array { - if ($filter === null) throw ValueException::null("filter"); - $this->verifixFilter($channel, $filter); - $row = $this->sqlite->one([ - "select", - "from" => $channel->getTableName(), - "where" => $filter, - ]); - return $this->unserialize($channel, $row); - } - - function _all(CapacitorChannel $channel, $filter): iterable { - $this->verifixFilter($channel, $filter); - $rows = $this->sqlite->all([ - "select", - "from" => $channel->getTableName(), - "where" => $filter, - ]); - foreach ($rows as $row) { - yield $this->unserialize($channel, $row); - } - } - - function _each(CapacitorChannel $channel, $filter, ?callable $func, ?array $args): int { - if ($func === null) $func = [$channel, "onEach"]; - $onEach = func::_prepare($func); - $sqlite = $this->sqlite; - $tableName = $channel->getTableName(); - $commited = false; - $count = 0; - $sqlite->beginTransaction(); - $commitThreshold = $channel->getEachCommitThreshold(); - try { - $args ??= []; - foreach ($this->_all($channel, $filter) as $row) { - $updates = func::_call($onEach, [$row["item"], $row, ...$args]); - if (is_array($updates)) { - $updates = $this->serialize($channel, $updates); - if (array_key_exists("item__", $updates)) { - # si item a été mis à jour, il faut mettre à jour sum_ - $updates["sum_"] = sha1($updates["item__"]); - if (!array_key_exists("modified_", $updates)) { - $updates["modified_"] = date("Y-m-d H:i:s"); - } - } - $sqlite->exec([ - "update", - "table" => $tableName, - "values" => $updates, - "where" => ["id_" => $row["id_"]], - ]); - if ($commitThreshold !== null) { - $commitThreshold--; - if ($commitThreshold == 0) { - $sqlite->commit(); - $sqlite->beginTransaction(); - $commitThreshold = $channel->getEachCommitThreshold(); - } - } - } - $count++; - } - $sqlite->commit(); - $commited = true; - return $count; - } finally { - if (!$commited) $sqlite->rollback(); - } - } - function close(): void { - $this->sqlite->close(); + $this->db->close(); } } diff --git a/php/src/db/sqlite/_query.php b/php/src/db/sqlite/_query.php deleted file mode 100644 index 9f4f038..0000000 --- a/php/src/db/sqlite/_query.php +++ /dev/null @@ -1,215 +0,0 @@ - $value) { - if ($key === $index) { - $index++; - if ($sql && !str::ends_with(" ", $sql) && !str::starts_with(" ", $value)) { - $sql .= " "; - } - $sql .= $value; - } - } - return $sql; - } - - protected static function is_sep(&$cond): bool { - if (!is_string($cond)) return false; - if (!preg_match('/^\s*(and|or|not)\s*$/i', $cond, $ms)) return false; - $cond = $ms[1]; - return true; - } - - static function parse_conds(?array $conds, ?array &$sql, ?array &$params): void { - if (!$conds) return; - $sep = null; - $index = 0; - $condsql = []; - foreach ($conds as $key => $cond) { - if ($key === $index) { - ## séquentiel - if ($index === 0 && self::is_sep($cond)) { - $sep = $cond; - } elseif (is_array($cond)) { - # condition récursive - self::parse_conds($cond, $condsql, $params); - } else { - # condition litérale - $condsql[] = strval($cond); - } - $index++; - } else { - ## associatif - # paramètre - $param = $key; - if ($params !== null && array_key_exists($param, $params)) { - $i = 1; - while (array_key_exists("$key$i", $params)) { - $i++; - } - $param = "$key$i"; - } - # value ou [operator, value] - if (is_array($cond)) { - #XXX implémenter le support de ["between", lower, upper] - # et aussi ["in", values] - $op = null; - $value = null; - $condkeys = array_keys($cond); - if (array_key_exists("op", $cond)) $op = $cond["op"]; - if (array_key_exists("value", $cond)) $value = $cond["value"]; - $condkey = 0; - if ($op === null && array_key_exists($condkey, $condkeys)) { - $op = $cond[$condkeys[$condkey]]; - $condkey++; - } - if ($value === null && array_key_exists($condkey, $condkeys)) { - $value = $cond[$condkeys[$condkey]]; - $condkey++; - } - } elseif ($cond !== null) { - $op = "="; - $value = $cond; - } else { - $op = "is null"; - $value = null; - } - $cond = [$key, $op]; - if ($value !== null) { - $cond[] = ":$param"; - $params[$param] = $value; - } - $condsql[] = implode(" ", $cond); - } - } - if ($sep === null) $sep = "and"; - $count = count($condsql); - if ($count > 1) { - $sql[] = "(" . implode(" $sep ", $condsql) . ")"; - } elseif ($count == 1) { - $sql[] = $condsql[0]; - } - } - - static function parse_set_values(?array $values, ?array &$sql, ?array &$params): void { - if (!$values) return; - $index = 0; - $parts = []; - foreach ($values as $key => $part) { - if ($key === $index) { - ## séquentiel - if (is_array($part)) { - # paramètres récursifs - self::parse_set_values($part, $parts, $params); - } else { - # paramètre litéral - $parts[] = strval($part); - } - $index++; - } else { - ## associatif - # paramètre - $param = $key; - if ($params !== null && array_key_exists($param, $params)) { - $i = 1; - while (array_key_exists("$key$i", $params)) { - $i++; - } - $param = "$key$i"; - } - # value - $value = $part; - $part = [$key, "="]; - if ($value === null) { - $part[] = "null"; - } else { - $part[] = ":$param"; - $params[$param] = $value; - } - $parts[] = implode(" ", $part); - } - } - $sql = cl::merge($sql, $parts); - } - - protected static function check_eof(string $tmpsql, string $usersql): void { - self::consume(';\s*', $tmpsql); - if ($tmpsql) { - throw new ValueException("unexpected value at end: $usersql"); - } - } - - function __construct($sql, ?array $params=null) { - self::verifix($sql, $params); - $this->sql = $sql; - $this->params = $params; - } - - /** @var string */ - protected $sql; - - /** @var ?array */ - protected $params; - - function useStmt(SQLite3 $db, ?SQLite3Stmt &$stmt=null, ?string &$sql=null): bool { - if ($this->params !== null) { - /** @var SQLite3Stmt $stmt */ - $stmt = SqliteException::check($db, $db->prepare($this->sql)); - $close = true; - try { - foreach ($this->params as $param => $value) { - SqliteException::check($db, $stmt->bindValue($param, $value)); - } - $close = false; - return true; - } finally { - if ($close) $stmt->close(); - } - } else { - $sql = $this->sql; - return false; - } - } -} diff --git a/php/src/db/sqlite/_query_base.php b/php/src/db/sqlite/_query_base.php new file mode 100644 index 0000000..8a6128a --- /dev/null +++ b/php/src/db/sqlite/_query_base.php @@ -0,0 +1,57 @@ +sql); #XXX + if ($this->bindings !== null) { + /** @var SQLite3Stmt $stmt */ + $stmt = SqliteException::check($db, $db->prepare($this->sql)); + $close = true; + try { + foreach ($this->bindings as $param => $value) { + SqliteException::check($db, $stmt->bindValue($param, $value)); + } + $close = false; + return true; + } finally { + if ($close) $stmt->close(); + } + } else { + $sql = $this->sql; + return false; + } + } +} diff --git a/php/src/db/sqlite/_query_create.php b/php/src/db/sqlite/_query_create.php index 84868ff..5aa7aa1 100644 --- a/php/src/db/sqlite/_query_create.php +++ b/php/src/db/sqlite/_query_create.php @@ -1,47 +1,10 @@ "?string", - "table" => "string", - "schema" => "?array", - "cols" => "?array", - "suffix" => "?string", - ]; +use nulib\db\_private\_create; +use nulib\db\_private\Tcreate; - static function isa(string $sql): bool { - //return preg_match("/^create(?:\s+table)?\b/i", $sql); - #XXX implémentation minimale - return preg_match("/^create\s+table\b/i", $sql); - } - - static function parse(array $query, ?array &$params=null): string { - #XXX implémentation minimale - $sql = [self::merge_seq($query)]; - - ## préfixe - if (($prefix = $query["prefix"] ?? null) !== null) $sql[] = $prefix; - - ## table - $sql[] = $query["table"]; - - ## columns - $cols = $query["cols"]; - $index = 0; - foreach ($cols as $col => &$definition) { - if ($col === $index) { - $index++; - } else { - $definition = "$col $definition"; - } - }; unset($definition); - $sql[] = "(".implode(", ", $cols).")"; - - ## suffixe - if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; - - ## fin de la requête - return implode(" ", $sql); - } +class _query_create extends _query_base { + use Tcreate; + const SCHEMA = _create::SCHEMA; } diff --git a/php/src/db/sqlite/_query_delete.php b/php/src/db/sqlite/_query_delete.php index 7d24092..f6adcd3 100644 --- a/php/src/db/sqlite/_query_delete.php +++ b/php/src/db/sqlite/_query_delete.php @@ -1,44 +1,10 @@ "?string", - "from" => "?string", - "where" => "?array", - "suffix" => "?string", - ]; +use nulib\db\_private\_delete; +use nulib\db\_private\Tdelete; - static function isa(string $sql): bool { - //return preg_match("/^delete(?:\s+from)?\b/i", $sql); - #XXX implémentation minimale - return preg_match("/^delete\s+from\b/i", $sql); - } - - static function parse(array $query, ?array &$params=null): string { - #XXX implémentation minimale - $sql = [self::merge_seq($query)]; - - ## préfixe - if (($prefix = $query["prefix"] ?? null) !== null) $sql[] = $prefix; - - ## table - $sql[] = $query["table"]; - - ## where - $where = $query["where"] ?? null; - if ($where !== null) { - _query::parse_conds($where, $wheresql, $params); - if ($wheresql) { - $sql[] = "where"; - $sql[] = implode(" and ", $wheresql); - } - } - - ## suffixe - if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; - - ## fin de la requête - return implode(" ", $sql); - } +class _query_delete extends _query_base { + use Tdelete; + const SCHEMA = _delete::SCHEMA; } diff --git a/php/src/db/sqlite/_query_generic.php b/php/src/db/sqlite/_query_generic.php index 78f37e4..f989291 100644 --- a/php/src/db/sqlite/_query_generic.php +++ b/php/src/db/sqlite/_query_generic.php @@ -1,18 +1,10 @@ "?string", - "into" => "?string", - "schema" => "?array", - "cols" => "?array", - "values" => "?array", - "suffix" => "?string", - ]; - - static function isa(string $sql): bool { - return preg_match("/^insert\b/i", $sql); - } - - /** - * parser une chaine de la forme - * "insert [into] [TABLE] [(COLS)] [values (VALUES)]" - */ - static function parse(array $query, ?array &$params=null): string { - # fusionner d'abord toutes les parties séquentielles - $usersql = $tmpsql = self::merge_seq($query); - - ### vérifier la présence des parties nécessaires - $sql = []; - if (($prefix = $query["prefix"] ?? null) !== null) $sql[] = $prefix; - - ## insert - self::consume('insert\s*', $tmpsql); - $sql[] = "insert"; - - ## into - self::consume('into\s*', $tmpsql); - $sql[] = "into"; - $into = $query["into"] ?? null; - if (self::consume('([a-z_][a-z0-9_]*)\s*', $tmpsql, $ms)) { - if ($into === null) $into = $ms[1]; - $sql[] = $into; - } elseif ($into !== null) { - $sql[] = $into; - } else { - throw new ValueException("expected table name: $usersql"); - } - - ## cols & values - $usercols = []; - $uservalues = []; - if (self::consume('\(([^)]*)\)\s*', $tmpsql, $ms)) { - $usercols = array_merge($usercols, preg_split("/\s*,\s*/", $ms[1])); - } - $cols = cl::withn($query["cols"] ?? null); - $values = cl::withn($query["values"] ?? null); - $schema = $query["schema"] ?? null; - if ($cols === null) { - if ($usercols) { - $cols = $usercols; - } elseif ($values) { - $cols = array_keys($values); - $usercols = array_merge($usercols, $cols); - } elseif ($schema && is_array($schema)) { - #XXX implémenter support AssocSchema - $cols = array_keys($schema); - $usercols = array_merge($usercols, $cols); - } - } - if (self::consume('values\s+\(\s*(.*)\s*\)\s*', $tmpsql, $ms)) { - if ($ms[1]) $uservalues[] = $ms[1]; - } - if ($cols !== null && !$uservalues) { - if (!$usercols) $usercols = $cols; - foreach ($cols as $col) { - $uservalues[] = ":$col"; - $params[$col] = $values[$col] ?? null; - } - } - $sql[] = "(" . implode(", ", $usercols) . ")"; - $sql[] = "values (" . implode(", ", $uservalues) . ")"; - - ## suffixe - if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; - - ## fin de la requête - self::check_eof($tmpsql, $usersql); - return implode(" ", $sql); - } +class _query_insert extends _query_base { + use Tinsert; + const SCHEMA = _insert::SCHEMA; } diff --git a/php/src/db/sqlite/_query_select.php b/php/src/db/sqlite/_query_select.php index a37e957..73d27a6 100644 --- a/php/src/db/sqlite/_query_select.php +++ b/php/src/db/sqlite/_query_select.php @@ -1,169 +1,10 @@ "?string", - "schema" => "?array", - "cols" => "?array", - "from" => "?string", - "where" => "?array", - "order by" => "?array", - "group by" => "?array", - "having" => "?array", - "suffix" => "?string", - ]; - - static function isa(string $sql): bool { - return preg_match("/^select\b/i", $sql); - } - - /** - * parser une chaine de la forme - * "select [COLS] [from TABLE] [where CONDS] [order by ORDERS] [group by GROUPS] [having CONDS]" - */ - static function parse(array $query, ?array &$params=null): string { - # fusionner d'abord toutes les parties séquentielles - $usersql = $tmpsql = self::merge_seq($query); - - ### vérifier la présence des parties nécessaires - $sql = []; - - ## préfixe - if (($prefix = $query["prefix"] ?? null) !== null) $sql[] = $prefix; - - ## select - self::consume('select\s*', $tmpsql); - $sql[] = "select"; - - ## cols - $usercols = []; - if (self::consume('(.*?)\s*(?=$|\bfrom\b)', $tmpsql, $ms)) { - if ($ms[1]) $usercols[] = $ms[1]; - } - $tmpcols = cl::withn($query["cols"] ?? null); - $schema = $query["schema"] ?? null; - if ($tmpcols !== null) { - $cols = []; - $index = 0; - foreach ($tmpcols as $key => $col) { - if ($key === $index) { - $index++; - $cols[] = $col; - $usercols[] = $col; - } else { - $cols[] = $key; - $usercols[] = "$col as $key"; - } - } - } else { - $cols = null; - if ($schema && is_array($schema) && !in_array("*", $usercols)) { - $cols = array_keys($schema); - $usercols = array_merge($usercols, $cols); - } - } - if (!$usercols && !$cols) $usercols = ["*"]; - $sql[] = implode(" ", $usercols); - - ## from - $from = $query["from"] ?? null; - if (self::consume('from\s+([a-z_][a-z0-9_]*)\s*(?=;?\s*$|\bwhere\b)', $tmpsql, $ms)) { - if ($from === null) $from = $ms[1]; - $sql[] = "from"; - $sql[] = $from; - } elseif ($from !== null) { - $sql[] = "from"; - $sql[] = $from; - } else { - throw new ValueException("expected table name: $usersql"); - } - - ## where - $userwhere = []; - if (self::consume('where\b\s*(.*?)(?=;?\s*$|\border\s+by\b)', $tmpsql, $ms)) { - if ($ms[1]) $userwhere[] = $ms[1]; - } - $where = cl::withn($query["where"] ?? null); - if ($where !== null) self::parse_conds($where, $userwhere, $params); - if ($userwhere) { - $sql[] = "where"; - $sql[] = implode(" and ", $userwhere); - } - - ## order by - $userorderby = []; - if (self::consume('order\s+by\b\s*(.*?)(?=;?\s*$|\bgroup\s+by\b)', $tmpsql, $ms)) { - if ($ms[1]) $userorderby[] = $ms[1]; - } - $orderby = cl::withn($query["order by"] ?? null); - if ($orderby !== null) { - $index = 0; - foreach ($orderby as $key => $value) { - if ($key === $index) { - $userorderby[] = $value; - $index++; - } else { - if ($value === null) $value = false; - if (!is_bool($value)) { - $userorderby[] = "$key $value"; - } elseif ($value) { - $userorderby[] = $key; - } - } - } - } - if ($userorderby) { - $sql[] = "order by"; - $sql[] = implode(", ", $userorderby); - } - ## group by - $usergroupby = []; - if (self::consume('group\s+by\b\s*(.*?)(?=;?\s*$|\bhaving\b)', $tmpsql, $ms)) { - if ($ms[1]) $usergroupby[] = $ms[1]; - } - $groupby = cl::withn($query["group by"] ?? null); - if ($groupby !== null) { - $index = 0; - foreach ($groupby as $key => $value) { - if ($key === $index) { - $usergroupby[] = $value; - $index++; - } else { - if ($value === null) $value = false; - if (!is_bool($value)) { - $usergroupby[] = "$key $value"; - } elseif ($value) { - $usergroupby[] = $key; - } - } - } - } - if ($usergroupby) { - $sql[] = "group by"; - $sql[] = implode(", ", $usergroupby); - } - - ## having - $userhaving = []; - if (self::consume('having\b\s*(.*?)(?=;?\s*$)', $tmpsql, $ms)) { - if ($ms[1]) $userhaving[] = $ms[1]; - } - $having = cl::withn($query["having"] ?? null); - if ($having !== null) self::parse_conds($having, $userhaving, $params); - if ($userhaving) { - $sql[] = "having"; - $sql[] = implode(" and ", $userhaving); - } - - ## suffixe - if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; - - ## fin de la requête - self::check_eof($tmpsql, $usersql); - return implode(" ", $sql); - } +class _query_select extends _query_base { + use Tselect; + const SCHEMA = _select::SCHEMA; } diff --git a/php/src/db/sqlite/_query_update.php b/php/src/db/sqlite/_query_update.php index 3620f04..41afc8b 100644 --- a/php/src/db/sqlite/_query_update.php +++ b/php/src/db/sqlite/_query_update.php @@ -1,50 +1,10 @@ "?string", - "table" => "?string", - "schema" => "?array", - "cols" => "?array", - "values" => "?array", - "where" => "?array", - "suffix" => "?string", - ]; +use nulib\db\_private\_update; +use nulib\db\_private\Tupdate; - static function isa(string $sql): bool { - return preg_match("/^update\b/i", $sql); - } - - static function parse(array $query, ?array &$params=null): string { - #XXX implémentation minimale - $sql = [self::merge_seq($query)]; - - ## préfixe - if (($prefix = $query["prefix"] ?? null) !== null) $sql[] = $prefix; - - ## table - $sql[] = $query["table"]; - - ## set - _query::parse_set_values($query["values"], $setsql, $params); - $sql[] = "set"; - $sql[] = implode(", ", $setsql); - - ## where - $where = $query["where"] ?? null; - if ($where !== null) { - _query::parse_conds($where, $wheresql, $params); - if ($wheresql) { - $sql[] = "where"; - $sql[] = implode(" and ", $wheresql); - } - } - - ## suffixe - if (($suffix = $query["suffix"] ?? null) !== null) $sql[] = $suffix; - - ## fin de la requête - return implode(" ", $sql); - } +class _query_update extends _query_base { + use Tupdate; + const SCHEMA = _update::SCHEMA; } diff --git a/php/src/ext/spreadsheet/PhpSpreadsheetBuilder.php b/php/src/ext/spreadsheet/PhpSpreadsheetBuilder.php new file mode 100644 index 0000000..ee76efd --- /dev/null +++ b/php/src/ext/spreadsheet/PhpSpreadsheetBuilder.php @@ -0,0 +1,112 @@ +ss = new Spreadsheet(); + $this->valueBinder = new StringValueBinder(); + $this->setWsname($params["wsname"] ?? static::WSNAME); + } + + protected Spreadsheet $ss; + + protected IValueBinder $valueBinder; + + protected ?Worksheet $ws; + + protected int $nrow; + + const STYLE_ROW = 0, STYLE_HEADER = 1; + + protected int $rowStyle; + + /** + * @param string|int|null $wsname + */ + function setWsname($wsname): self { + $ss = $this->ss; + $this->ws = null; + $this->nrow = 0; + $this->rowStyle = self::STYLE_ROW; + + $ws = wsutils::get_ws($wsname, $ss); + if ($ws === null) { + $ws = $ss->createSheet()->setTitle($wsname); + $this->wroteHeaders = false; + } else { + $maxRow = wsutils::compute_max_coords($ws)[1]; + $this->nrow = $maxRow - 1; + $this->wroteHeaders = $maxRow > 1; + } + $this->ws = $ws; + return $this; + } + + function _write(array $row): void { + $ws = $this->ws; + $styleHeader = $this->rowStyle === self::STYLE_HEADER; + $nrow = ++$this->nrow; + $ncol = 1; + foreach ($row as $col) { + $ws->getCellByColumnAndRow($ncol++, $nrow)->setValue($col, $this->valueBinder); + } + if ($styleHeader) { + $ws->getStyle("$nrow:$nrow")->getFont()->setBold(true); + $maxcol = count($row); + for ($ncol = 1; $ncol <= $maxcol; $ncol++) { + $ws->getColumnDimensionByColumn($ncol)->setAutoSize(true); + } + } + } + + function writeHeaders(?array $headers=null): void { + $this->rowStyle = self::STYLE_HEADER; + parent::writeHeaders($headers); + $this->rowStyle = self::STYLE_ROW; + } + + function _sendContentType(): void { + switch (path::ext($this->output)) { + case ".ods": + $contentType = "application/vnd.oasis.opendocument.spreadsheet"; + break; + case ".xlsx": + default: + $contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + break; + } + http::content_type($contentType); + } + + protected function _checkOk(): bool { + switch (path::ext($this->output)) { + case ".ods": + $writer = new Ods($this->ss); + break; + case ".xlsx": + default: + $writer = new Xlsx($this->ss); + break; + } + $writer->save($this->getResource()); + $this->rewind(); + return true; + } +} diff --git a/php/src/ext/spreadsheet/PhpSpreadsheetReader.php b/php/src/ext/spreadsheet/PhpSpreadsheetReader.php new file mode 100644 index 0000000..02c1138 --- /dev/null +++ b/php/src/ext/spreadsheet/PhpSpreadsheetReader.php @@ -0,0 +1,116 @@ + self::DATETIME_FORMAT, + 'dd/mm hh' => self::DATETIME_FORMAT, + 'mm/dd hh:mm' => self::DATETIME_FORMAT, + 'dd/mm hh:mm' => self::DATETIME_FORMAT, + 'mm/dd hh:mm:ss' => self::DATETIME_FORMAT, + 'dd/mm hh:mm:ss' => self::DATETIME_FORMAT, + 'mm/dd/yyyy hh' => self::DATETIME_FORMAT, + 'dd/mm/yyyy hh' => self::DATETIME_FORMAT, + 'mm/dd/yyyy hh:mm' => self::DATETIME_FORMAT, + 'dd/mm/yyyy hh:mm' => self::DATETIME_FORMAT, + 'mm/dd/yyyy hh:mm:ss' => self::DATETIME_FORMAT, + 'dd/mm/yyyy hh:mm:ss' => self::DATETIME_FORMAT, + 'yyyy/mm/dd hh' => self::DATETIME_FORMAT, + 'yyyy/mm/dd hh:mm' => self::DATETIME_FORMAT, + 'yyyy/mm/dd hh:mm:ss' => self::DATETIME_FORMAT, + + 'mm/dd' => self::DATE_FORMAT, + 'dd/mm' => self::DATE_FORMAT, + 'mm/dd/yyyy' => self::DATE_FORMAT, + 'dd/mm/yyyy' => self::DATE_FORMAT, + 'yyyy/mm/dd' => self::DATE_FORMAT, + 'mm/yyyy' => self::DATE_FORMAT, + + 'hh AM/PM' => self::TIME_FORMAT, + 'hh:mm AM/PM' => self::TIME_FORMAT, + 'hh:mm:ss AM/PM' => self::TIME_FORMAT, + 'hh' => self::TIME_FORMAT, + 'hh:mm' => self::TIME_FORMAT, + 'hh:mm:ss' => self::TIME_FORMAT, + '[hh]:mm:ss' => self::TIME_FORMAT, + 'mm:ss' => self::TIME_FORMAT, + ]; + + /** @var string|int|null nom de la feuille depuis laquelle lire */ + const WSNAME = null; + + function __construct($input, ?array $params=null) { + parent::__construct($input, $params); + $this->wsname = $params["wsname"] ?? static::WSNAME; + } + + protected $wsname; + + /** + * @param string|int|null $wsname + */ + function setWsname($wsname): self { + $this->wsname = $wsname; + return $this; + } + + function getIterator() { + $ss = IOFactory::load($this->input); + $ws = wsutils::get_ws($this->wsname, $ss); + + [$nbCols, $nbRows] = wsutils::compute_max_coords($ws); + $this->isrc = $this->idest = 0; + for ($nrow = 1; $nrow <= $nbRows; $nrow++) { + $row = []; + for ($ncol = 1; $ncol <= $nbCols; $ncol++) { + if ($ws->cellExistsByColumnAndRow($ncol, $nrow)) { + $cell = $ws->getCellByColumnAndRow($ncol, $nrow); + $col = $cell->getValue(); + if ($col instanceof RichText) { + $col = $col->getPlainText(); + } else { + $dataType = $cell->getDataType(); + if ($dataType == DataType::TYPE_NUMERIC || $dataType == DataType::TYPE_FORMULA) { + # si c'est un format date, le forcer à une valeur standard + $origFormatCode = $cell->getStyle()->getNumberFormat()->getFormatCode(); + if (strpbrk($origFormatCode, "ymdhs") !== false) { + $formatCode = $origFormatCode; + $formatCode = preg_replace('/y+/', "yyyy", $formatCode); + $formatCode = preg_replace('/m+/', "mm", $formatCode); + $formatCode = preg_replace('/d+/', "dd", $formatCode); + $formatCode = preg_replace('/h+/', "hh", $formatCode); + $formatCode = preg_replace('/s+/', "ss", $formatCode); + $formatCode = preg_replace('/-+/', "/", $formatCode); + $formatCode = preg_replace('/\\\\ /', " ", $formatCode); + $formatCode = preg_replace('/;@$/', "", $formatCode); + $formatCode = cl::get(self::FORMAT_MAPPINGS, $formatCode, $formatCode); + if ($formatCode !== $origFormatCode) { + $cell->getStyle()->getNumberFormat()->setFormatCode($formatCode); + } + } + } + $col = $cell->getFormattedValue(); + $this->verifixCol($col); + } + } else { + $col = null; + } + $row[] = $col; + } + if ($this->cook($row)) { + yield $row; + $this->idest++; + } + $this->isrc++; + } + } +} diff --git a/php/src/ext/spreadsheet/SpoutBuilder.php b/php/src/ext/spreadsheet/SpoutBuilder.php new file mode 100644 index 0000000..7a6b09f --- /dev/null +++ b/php/src/ext/spreadsheet/SpoutBuilder.php @@ -0,0 +1,202 @@ +output)) { + case ".ods": + $ssType = "ods"; + break; + case ".xlsx": + default: + $ssType = "xlsx"; + break; + } + } + switch ($ssType) { + case "ods": + $ss = WriterEntityFactory::createODSWriter(); + break; + case "xlsx": + default: + $ss = WriterEntityFactory::createXLSXWriter(); + break; + } + $ss->setDefaultColumnWidth(10.5); + $ss->writeToStream($this->getResource()); + $this->ss = $ss; + $this->typeNumeric = boolval($params["type_numeric"] ?? static::TYPE_NUMERIC); + $this->typeDate = boolval($params["type_date"] ?? static::TYPE_DATE); + $this->firstSheet = true; + $this->setWsname($params["wsname"] ?? static::WSNAME); + } + + protected WriterMultiSheetsAbstract $ss; + + protected bool $typeNumeric; + + protected bool $typeDate; + + const STYLE_ROW = 0, STYLE_HEADER = 1; + + protected int $rowStyle; + + protected bool $firstSheet; + + /** + * @param string|int|null $wsname + */ + function setWsname($wsname, ?array $params=null): self { + $ss = $this->ss; + $this->rowStyle = self::STYLE_ROW; + if ($this->firstSheet) { + $this->firstSheet = false; + $ws = $ss->getCurrentSheet(); + } else { + $ws = $ss->addNewSheetAndMakeItCurrent(); + $this->wroteHeaders = false; + $this->built = false; + } + $wsname ??= $params["wsname"] ?? null; + if ($wsname !== null) $ws->setName($wsname); + $sheetView = (new SheetView()) + ->setFreezeRow(2); + $ws->setSheetView($sheetView); + if ($params !== null) { + if (array_key_exists("schema", $params)) { + $this->schema = $params["schema"] ?? null; + } + if (array_key_exists("headers", $params)) { + $this->headers = $params["headers"] ?? null; + } + if (array_key_exists("rows", $params)) { + $rows = $params["rows"] ?? null; + if (is_callable($rows)) $rows = $rows(); + $this->rows = $rows; + } + if (array_key_exists("cook_func", $params)) { + $cookFunc = $params["cook_func"] ?? null; + $cookCtx = $cookArgs = null; + if ($cookFunc !== null) { + func::ensure_func($cookFunc, $this, $cookArgs); + $cookCtx = func::_prepare($cookFunc); + } + $this->cookCtx = $cookCtx; + $this->cookArgs = $cookArgs; + } + if (array_key_exists("type_numeric", $params)) { + $this->typeNumeric = boolval($params["type_numeric"] ?? static::TYPE_NUMERIC); + } + if (array_key_exists("type_date", $params)) { + $this->typeDate = boolval($params["type_date"] ?? static::TYPE_DATE); + } + } + return $this; + } + + protected function isNumeric($value): bool { + if ($this->typeNumeric && is_numeric($value)) return true; + if (!is_string($value) && is_numeric($value)) return true; + return false; + } + + protected function isDate(&$value, &$style): bool { + if (CellTypeHelper::isDateTimeOrDateInterval($value)) { + $style = (new Style())->setFormat(self::DATE_FORMAT); + return true; + } + if (!is_string($value) || !$this->typeDate) return false; + if (DateTime::isa_datetime($value, true)) { + $value = new DateTime($value); + $style = (new Style())->setFormat(self::DATETIME_FORMAT); + return true; + } + if (DateTime::isa_date($value, true)) { + $value = new Date($value); + $style = (new Style())->setFormat(self::DATE_FORMAT); + return true; + } + return false; + } + + function _write(array $row): void { + $cells = []; + $rowStyle = null; + foreach ($row as $col) { + $style = null; + if ($col === null || $col === "") { + $type = Cell::TYPE_EMPTY; + } elseif ($this->isNumeric($col)) { + $type = Cell::TYPE_NUMERIC; + } elseif ($this->isDate($col, $style)) { + $type = Cell::TYPE_DATE; + } else { + $type = Cell::TYPE_STRING; + } + $cell = WriterEntityFactory::createCell($col, $style); + $cell->setType($type); + $cells[] = $cell; + } + if ($this->rowStyle === self::STYLE_HEADER) { + $rowStyle = (new Style())->setFontBold(); + } + $this->ss->addRow(WriterEntityFactory::createRow($cells, $rowStyle)); + } + + function writeHeaders(?array $headers=null): void { + $this->rowStyle = self::STYLE_HEADER; + parent::writeHeaders($headers); + $this->rowStyle = self::STYLE_ROW; + } + + function _sendContentType(): void { + switch (path::ext($this->output)) { + case ".ods": + $contentType = "application/vnd.oasis.opendocument.spreadsheet"; + break; + case ".xlsx": + default: + $contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + break; + } + http::content_type($contentType); + } + + protected function _checkOk(): bool { + $this->ss->close(); + $this->rewind(); + return true; + } +} diff --git a/php/src/ext/spreadsheet/SpoutReader.php b/php/src/ext/spreadsheet/SpoutReader.php new file mode 100644 index 0000000..48aa0df --- /dev/null +++ b/php/src/ext/spreadsheet/SpoutReader.php @@ -0,0 +1,121 @@ +ssType = $params["ss_type"] ?? null; + $this->allSheets = $params["all_sheets"] ?? true; + $wsname = static::WSNAME; + if ($params !== null && array_key_exists("wsname", $params)) { + # spécifié par l'utilisateur: $allSheets = false + $this->setWsname($params["wsname"]); + } elseif ($wsname !== null) { + # valeur non nulle de la classe: $allSheets = false + $this->setWsname($wsname); + } else { + # pas de valeur définie dans la classe, laisser $allSheets à sa valeur + # actuelle + $this->wsname = null; + } + $this->includeWsnames = cl::withn($params["include_wsnames"] ?? null); + $this->excludeWsnames = cl::withn($params["exclude_wsnames"] ?? null); + } + + protected ?string $ssType; + + /** @var bool faut-il retourner les lignes de toutes les feuilles? */ + protected bool $allSheets; + + function setAllSheets(bool $allSheets=true): self { + $this->allSheets = $allSheets; + return $this; + } + + /** + * @var array|null si non null, liste de feuilles à inclure. n'est pris en + * compte que si $allSheets===true + */ + protected ?array $includeWsnames; + + /** + * @var array|null si non null, liste de feuilles à exclure. n'est pris en + * compte que si $allSheets===true + */ + protected ?array $excludeWsnames; + + protected $wsname; + + /** + * @param string|int|null $wsname l'unique feuille à sélectionner + * + * NB: appeler cette méthode réinitialise $allSheets à false + */ + function setWsname($wsname): self { + $this->wsname = $wsname; + $this->allSheets = true; + return $this; + } + + function getIterator() { + switch ($this->ssType) { + case "ods": + $ss = ReaderEntityFactory::createODSReader(); + break; + case "xlsx": + $ss = ReaderEntityFactory::createXLSXReader(); + break; + default: + $ss = ReaderEntityFactory::createReaderFromFile($this->input); + break; + } + $ss->open($this->input); + try { + $allSheets = $this->allSheets; + $includeWsnames = $this->includeWsnames; + $excludeWsnames = $this->excludeWsnames; + $wsname = $this->wsname; + $first = true; + foreach ($ss->getSheetIterator() as $ws) { + if ($allSheets) { + $wsname = $ws->getName(); + $found = ($includeWsnames === null || in_array($wsname, $includeWsnames)) + && ($excludeWsnames === null || !in_array($wsname, $excludeWsnames)); + } else { + $found = $wsname === null || $wsname === $ws->getName(); + } + if ($found) { + if ($first) { + $first = false; + } else { + yield null; + # on garde le même schéma le cas échéant, mais supprimer headers + # pour permettre son recalcul + $this->headers = null; + } + $this->isrc = $this->idest = 0; + foreach ($ws->getRowIterator() as $row) { + $row = $row->toArray(); + foreach ($row as &$col) { + $this->verifixCol($col); + }; unset($col); + if ($this->cook($row)) { + yield $row; + $this->idest++; + } + $this->isrc++; + } + } + } + } finally { + $ss->close(); + } + } +} diff --git a/php/src/ext/spreadsheet/SsBuilder.php b/php/src/ext/spreadsheet/SsBuilder.php new file mode 100644 index 0000000..17d209d --- /dev/null +++ b/php/src/ext/spreadsheet/SsBuilder.php @@ -0,0 +1,9 @@ +getAllSheets() as $ws) { + $max_coords[$ws->getTitle()] = wsutils::compute_max_coords($ws); + } + return $max_coords; + } +} diff --git a/php/src/ext/spreadsheet/wsutils.php b/php/src/ext/spreadsheet/wsutils.php new file mode 100644 index 0000000..7fb33e1 --- /dev/null +++ b/php/src/ext/spreadsheet/wsutils.php @@ -0,0 +1,80 @@ +getActiveSheet(); + } elseif (is_numeric($wsname)) { + $sheetCount = $ss->getSheetCount(); + if ($wsname < 1 || $wsname > $sheetCount) { + throw ValueException::invalid_value($wsname, "sheet index"); + } + $ws = $ss->getSheet($wsname - 1); + } else { + $ws = $ss->getSheetByName($wsname); + if ($ws === null) { + if ($create) $ws = $ss->createSheet()->setTitle($wsname); + else throw ValueException::invalid_value($wsname, "sheet name"); + } + } + return $ws; + } + + static function get_highest_coords(Worksheet $ws): array { + $highestColumnA = $ws->getHighestColumn(); + $highestCol = Coordinate::columnIndexFromString($highestColumnA); + $highestRow = $ws->getHighestRow(); + return [$highestCol, $highestRow]; + } + + /** + * @var int nombre de colonnes/lignes au bout desquels on arrête de chercher + * si on n'a trouvé que des cellules vides. + * + * c'est nécessaire à cause de certains fichiers provenant d'Excel que j'ai + * reçus qui ont jusqu'à 10000 colonne vides et/ou 1048576 lignes vides. un + * algorithme "bête" perd énormément de temps à chercher dans le vide, donnant + * l'impression que le processus a planté. + */ + const MAX_EMPTY_THRESHOLD = 150; + + static function compute_max_coords(Worksheet $ws): array { + [$highestCol, $highestRow] = self::get_highest_coords($ws); + + $maxCol = 1; + $maxRow = 1; + $maxEmptyRows = self::MAX_EMPTY_THRESHOLD; + for ($row = 1; $row <= $highestRow; $row++) { + $emptyRow = true; + $maxEmptyCols = self::MAX_EMPTY_THRESHOLD; + for ($col = 1; $col <= $highestCol; $col++) { + $value = null; + if ($ws->cellExistsByColumnAndRow($col, $row)) { + $value = $ws->getCellByColumnAndRow($col, $row)->getValue(); + } + if ($value === null) { + $maxEmptyCols--; + if ($maxEmptyCols == 0) break; + } else { + $maxEmptyCols = self::MAX_EMPTY_THRESHOLD; + if ($row > $maxRow) $maxRow = $row; + if ($col > $maxCol) $maxCol = $col; + $emptyRow = false; + } + } + if ($emptyRow) { + $maxEmptyRows--; + if ($maxEmptyRows == 0) break; + } else { + $maxEmptyRows = self::MAX_EMPTY_THRESHOLD; + } + } + return [$maxCol, $maxRow]; + } +} diff --git a/php/src/file/FileReader.php b/php/src/file/FileReader.php index fb7b954..d663296 100644 --- a/php/src/file/FileReader.php +++ b/php/src/file/FileReader.php @@ -13,7 +13,7 @@ class FileReader extends _File { /** @var bool */ protected $ignoreBom; - function __construct($input, ?string $mode=null, bool $throwOnError=true, ?bool $allowLocking=null, ?bool $ignoreBom=null) { + function __construct($input, ?string $mode=null, ?bool $throwOnError=null, ?bool $allowLocking=null, ?bool $ignoreBom=null) { if ($ignoreBom === null) $ignoreBom = static::IGNORE_BOM; $this->ignoreBom = $ignoreBom; if ($input === null) { diff --git a/php/src/file/FileWriter.php b/php/src/file/FileWriter.php index 1d407bf..b3fdfc9 100644 --- a/php/src/file/FileWriter.php +++ b/php/src/file/FileWriter.php @@ -10,7 +10,7 @@ use nulib\os\sh; class FileWriter extends _File { const DEFAULT_MODE = "a+b"; - function __construct($output, ?string $mode=null, bool $throwOnError=true, ?bool $allowLocking=null) { + function __construct($output, ?string $mode=null, ?bool $throwOnError=null, ?bool $allowLocking=null) { if ($output === null) { $fd = STDOUT; $close = false; diff --git a/php/src/file/IReader.php b/php/src/file/IReader.php index 4ff2b36..36a351b 100644 --- a/php/src/file/IReader.php +++ b/php/src/file/IReader.php @@ -17,6 +17,8 @@ interface IReader extends _IFile { /** @throws IOException */ function fpassthru(): int; + function fgetcsv(): ?array; + /** * lire la prochaine ligne. la ligne est retournée *sans* le caractère de fin * de ligne [\r]\n @@ -26,19 +28,6 @@ interface IReader extends _IFile { */ function readLine(): ?string; - /** - * essayer de verrouiller le fichier en lecture. retourner true si l'opération - * réussit. dans ce cas, il faut appeler {@link getReader()} avec l'argument - * true - */ - function canRead(): bool; - - /** - * verrouiller en mode partagé puis retourner un objet permettant de lire le - * fichier. - */ - function getReader(bool $alreadyLocked=false): IReader; - /** * lire tout le contenu du fichier en une seule fois, puis, si $close==true, * le fermer @@ -49,4 +38,6 @@ interface IReader extends _IFile { /** désérialiser le contenu du fichier, puis, si $close===true, le fermer */ function unserialize(?array $options=null, bool $close=true, bool $alreadyLocked=false); + + function copyTo(IWriter $dest, bool $closeWriter=false, bool $closeReader=true): void; } diff --git a/php/src/file/IWriter.php b/php/src/file/IWriter.php index a1fd568..30a90d8 100644 --- a/php/src/file/IWriter.php +++ b/php/src/file/IWriter.php @@ -7,31 +7,21 @@ use nulib\os\IOException; * Interface IWriter: un objet dans lequel on peut écrire des données */ interface IWriter extends _IFile { + /** @throws IOException */ + function ftruncate(int $size): self; + /** @throws IOException */ function fwrite(string $data, int $length=0): int; + /** @throws IOException */ + function fputcsv(array $row): void; + /** @throws IOException */ function fflush(): self; - /** @throws IOException */ - function ftruncate(int $size): self; - /** afficher les lignes */ function writeLines(?iterable $lines): self; - /** - * essayer de verrouiller le fichier en écriture. retourner true si l'opération - * réussit. dans ce cas, il faut appeler {@link getWriter()} avec l'argument - * true - */ - function canWrite(): bool; - - /** - * verrouiller en mode exclusif puis retourner un objet permettant d'écrire - * dans le fichier - */ - function getWriter(bool $alreadyLocked=false): IWriter; - /** écrire le contenu spécifié dans le fichier */ function putContents(string $contents, bool $close=true, bool $alreadyLocked=false): void; diff --git a/php/src/file/MemoryStream.php b/php/src/file/MemoryStream.php index 4f5003d..52c82f2 100644 --- a/php/src/file/MemoryStream.php +++ b/php/src/file/MemoryStream.php @@ -10,7 +10,7 @@ class MemoryStream extends Stream { return fopen("php://memory", "w+b"); } - function __construct(bool $throwOnError=true) { + function __construct(?bool $throwOnError=null) { parent::__construct(self::memory_fd(), true, $throwOnError); } diff --git a/php/src/file/SharedFile.php b/php/src/file/SharedFile.php index 7854baf..c14001e 100644 --- a/php/src/file/SharedFile.php +++ b/php/src/file/SharedFile.php @@ -8,7 +8,7 @@ class SharedFile extends FileWriter { const DEFAULT_MODE = "c+b"; - function __construct($file, ?string $mode=null, bool $throwOnError=true, ?bool $allowLocking=null) { + function __construct($file, ?string $mode=null, ?bool $throwOnError=null, ?bool $allowLocking=null) { if ($file === null) throw ValueException::null("file"); parent::__construct($file, $mode, $throwOnError, $allowLocking); } diff --git a/php/src/file/Stream.php b/php/src/file/Stream.php index 7146274..8f25c4b 100644 --- a/php/src/file/Stream.php +++ b/php/src/file/Stream.php @@ -16,9 +16,29 @@ use nulib\ValueException; class Stream extends AbstractIterator implements IReader, IWriter { use TStreamFilter; + protected static function probe_fd($fd, ?bool &$seekable=null, ?bool &$readable=null): void { + $md = stream_get_meta_data($fd); + $seekable = $md["seekable"]; + $mode = $md["mode"]; + $readable = strpos($mode, "r") !== false || strpos($mode, "+") !== false; + } + + protected static function fd_is_seekable($fd): bool { + self::probe_fd($fd, $seekable); + return $seekable; + } + + protected static function fd_is_readable($fd): bool { + $mode = stream_get_meta_data($fd)["mode"]; + return strpos($mode, "r") !== false || strpos($mode, "+") !== false; + } + /** @var bool les opérations de verrouillages sont-elle activées? */ const USE_LOCKING = false; + /** @var bool faut-il lancer une exception s'il y a une erreur? */ + const THROW_ON_ERROR = true; + /** @var resource */ protected $fd; @@ -40,13 +60,12 @@ class Stream extends AbstractIterator implements IReader, IWriter { /** @var array */ protected $stat; - function __construct($fd, bool $close=true, bool $throwOnError=true, ?bool $useLocking=null) { + function __construct($fd, bool $close=true, ?bool $throwOnError=null, ?bool $useLocking=null) { if ($fd === null) throw ValueException::null("resource"); $this->fd = $fd; $this->close = $close; - $this->throwOnError = $throwOnError; - if ($useLocking === null) $useLocking = static::USE_LOCKING; - $this->useLocking = $useLocking; + $this->throwOnError = $throwOnError ?? static::THROW_ON_ERROR; + $this->useLocking = $useLocking ?? static::USE_LOCKING; } ############################################################################# @@ -143,24 +162,26 @@ class Stream extends AbstractIterator implements IReader, IWriter { const DEFAULT_CSV_FLAVOUR = ref_csv::OO_FLAVOUR; - /** @var array paramètres pour la lecture et l'écriture de flux au format CSV */ + /** @var string paramètres pour la lecture et l'écriture de flux au format CSV */ protected $csvFlavour; - function setCsvFlavour(string $flavour): void { + function setCsvFlavour(?string $flavour): void { $this->csvFlavour = csv_flavours::verifix($flavour); } protected function getCsvParams($fd): array { $flavour = $this->csvFlavour; if ($flavour === null) { + self::probe_fd($fd, $seekable, $readable); + if (!$seekable || !$readable) $fd = null; if ($fd === null) { # utiliser la valeur par défaut $flavour = static::DEFAULT_CSV_FLAVOUR; } else { # il faut déterminer le type de fichier CSV en lisant la première ligne $pos = IOException::ensure_valid(ftell($fd)); - $line = IOException::ensure_valid(fgets($fd)); - $line = strpbrk($line, ",;\t"); + $line = fgets($fd); + if ($line !== false) $line = strpbrk($line, ",;\t"); if ($line === false) { # aucun séparateur trouvé, prender la valeur par défaut $flavour = static::DEFAULT_CSV_FLAVOUR; @@ -259,13 +280,14 @@ class Stream extends AbstractIterator implements IReader, IWriter { return new class($this->fd, ++$this->serial, $this) extends Stream { function __construct($fd, int $serial, Stream $parent) { $this->parent = $parent; + $this->serial = $serial; parent::__construct($fd); } /** @var Stream */ private $parent; - function close(bool $close=true): void { + function close(bool $close=true, ?int $ifSerial=null): void { if ($this->parent !== null && $close) { $this->parent->close(true, $this->serial); $this->fd = null; @@ -293,13 +315,18 @@ class Stream extends AbstractIterator implements IReader, IWriter { return unserialize(...$args); } + function decodeJson(bool $close=true, bool $alreadyLocked=false) { + $contents = $this->getContents($close, $alreadyLocked); + return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } + ############################################################################# # Iterator - protected function _setup(): void { + protected function iter_setup(): void { } - protected function _next(&$key) { + protected function iter_next(&$key) { try { return $this->fgets(); } catch (EOFException $e) { @@ -307,14 +334,33 @@ class Stream extends AbstractIterator implements IReader, IWriter { } } - protected function _teardown(): void { - $md = stream_get_meta_data($this->fd); - if ($md["seekable"]) $this->fseek(0); + private function _rewindFd(): void { + self::probe_fd($this->fd, $seekable); + if ($seekable) $this->fseek(0); + } + + protected function iter_teardown(): void { + $this->_rewindFd(); + } + + function rewind(): void { + # il faut toujours faire un rewind sur la resource, que l'itérateur aie été + # initialisé ou non + if ($this->_hasIteratorBeenSetup()) parent::rewind(); + else $this->_rewindFd(); } ############################################################################# # Writer + /** @throws IOException */ + function ftruncate(int $size=0, bool $rewind=true): self { + $fd = $this->getResource(); + IOException::ensure_valid(ftruncate($fd, $size), $this->throwOnError); + if ($rewind) rewind($fd); + return $this; + } + /** @throws IOException */ function fwrite(string $data, ?int $length=null): int { $fd = $this->getResource(); @@ -323,10 +369,20 @@ class Stream extends AbstractIterator implements IReader, IWriter { return IOException::ensure_valid($r, $this->throwOnError); } + /** @throws IOException */ function fputcsv(array $row): void { $fd = $this->getResource(); $params = $this->getCsvParams($fd); - IOException::ensure_valid(fputcsv($fd, $row, $params[0], $params[1], $params[2])); + if (csv_flavours::is_dumb($this->csvFlavour, $sep)) { + $line = []; + foreach ($row as $col) { + $line[] = strval($col); + } + $line = implode($sep, $line); + IOException::ensure_valid(fwrite($fd, "$line\n"), $this->throwOnError); + } else { + IOException::ensure_valid(fputcsv($fd, $row, $params[0], $params[1], $params[2]), $this->throwOnError); + } } /** @throws IOException */ @@ -336,14 +392,6 @@ class Stream extends AbstractIterator implements IReader, IWriter { return $this; } - /** @throws IOException */ - function ftruncate(int $size=0, bool $rewind=true): self { - $fd = $this->getResource(); - IOException::ensure_valid(ftruncate($fd, $size), $this->throwOnError); - if ($rewind) rewind($fd); - return $this; - } - function writeLines(?iterable $lines): IWriter { if ($lines !== null) { foreach ($lines as $line) { @@ -388,7 +436,7 @@ class Stream extends AbstractIterator implements IReader, IWriter { /** @var Stream */ private $parent; - function close(bool $close=true): void { + function close(bool $close=true, ?int $ifSerial=null): void { if ($this->parent !== null && $close) { $this->parent->close(true, $this->serial); $this->fd = null; @@ -412,4 +460,17 @@ class Stream extends AbstractIterator implements IReader, IWriter { function serialize($object, bool $close=true, bool $alreadyLocked=false): void { $this->putContents(serialize($object), $close, $alreadyLocked); } + + function encodeJson($data, bool $close=true, bool $alreadyLocked=false): void { + $contents = json_encode($data, JSON_UNESCAPED_SLASHES + JSON_UNESCAPED_UNICODE); + $this->putContents($contents, $close, $alreadyLocked); + } + + /** + * annuler une tentative d'écriture commencée avec {@link self::canWrite()} + */ + function cancelWrite(bool $close=true): void { + if ($this->useLocking) $this->unlock($close); + elseif ($close) $this->close(); + } } diff --git a/php/src/file/TempStream.php b/php/src/file/TempStream.php index 633a045..28961c5 100644 --- a/php/src/file/TempStream.php +++ b/php/src/file/TempStream.php @@ -9,9 +9,8 @@ namespace nulib\file; class TempStream extends Stream { const MAX_MEMORY = 2 * 1024 * 1024; - function __construct(?int $maxMemory=null, bool $throwOnError=true) { - if ($maxMemory === null) $maxMemory = static::MAX_MEMORY; - $this->maxMemory = $maxMemory; + function __construct(?int $maxMemory=null, ?bool $throwOnError=null) { + $this->maxMemory = $maxMemory ?? static::MAX_MEMORY; parent::__construct($this->tempFd(), true, $throwOnError); } diff --git a/php/src/file/TmpfileWriter.php b/php/src/file/TmpfileWriter.php index ad69373..2292235 100644 --- a/php/src/file/TmpfileWriter.php +++ b/php/src/file/TmpfileWriter.php @@ -10,7 +10,7 @@ use nulib\os\path; class TmpfileWriter extends FileWriter { const DEFAULT_MODE = "w+b"; - function __construct(?string $destdir=null, ?string $mode=null, bool $throwOnError=true, ?bool $allowLocking=null) { + function __construct(?string $destdir=null, ?string $mode=null, ?bool $throwOnError=null, ?bool $allowLocking=null) { $tmpDir = sys_get_temp_dir(); if ($destdir === null) $destdir = $tmpDir; if (is_dir($destdir)) { @@ -39,6 +39,12 @@ class TmpfileWriter extends FileWriter { /** @var bool */ protected $delete; + /** désactiver la suppression automatique du fichier temporaire */ + function keep(): self { + $this->delete = false; + return $this; + } + function __destruct() { $this->close(); if ($this->delete) $this->delete(); diff --git a/php/src/file/_File.php b/php/src/file/_File.php index 21d37c0..80a50f2 100644 --- a/php/src/file/_File.php +++ b/php/src/file/_File.php @@ -5,10 +5,6 @@ use nulib\os\IOException; use nulib\web\http; abstract class _File extends Stream { - function __construct($fd, bool $close, bool $throwOnError=true, ?bool $allowLocking=null) { - parent::__construct($fd, $close, $throwOnError, $allowLocking); - } - /** @var string */ protected $file; diff --git a/php/src/file/app/RunFile.php b/php/src/file/app/RunFile.php deleted file mode 100644 index 52679be..0000000 --- a/php/src/file/app/RunFile.php +++ /dev/null @@ -1,140 +0,0 @@ -file = new SharedFile($file); - $this->name = $name ?? static::NAME; - } - - /** @var SharedFile */ - protected $file; - - /** @var ?string */ - protected $name; - - protected static function merge(array $data, array $merge): array { - return cl::merge($data, [ - "serial" => $data["serial"] + 1, - ], $merge); - } - - protected function initData(bool $withDateStart=true): array { - $dateStart = $withDateStart? new DateTime(): null; - return [ - "name" => $this->name, - "id" => bin2hex(random_bytes(16)), - "pid" => posix_getpid(), - "serial" => 0, - "date_start" => $dateStart, - "date_stop" => null, - "exitcode" => null, - "action" => null, - "action_date_start" => null, - "action_current_step" => null, - "action_max_step" => null, - "action_date_step" => null, - ]; - } - - function read(): array { - $data = $this->file->unserialize(); - if (!is_array($data)) $data = $this->initData(false); - return $data; - } - - /** tester si l'application est démarrée */ - function isStarted(): bool { - $data = $this->read(); - return $data["date_start"] !== null; - } - - /** tester si l'application est arrêtée */ - function isStopped(): bool { - $data = $this->read(); - return $data["date_stop"] !== null; - } - - function haveWorked(int $serial, ?int &$currentSerial=null): bool { - $data = $this->read(); - return $serial !== $data["serial"]; - } - - protected function willWrite(): array { - $file = $this->file; - $file->lockWrite(); - $data = $file->unserialize(null, false, true); - if (!is_array($data)) { - $data = $this->initData(); - $file->ftruncate(); - $file->serialize($data, false, true); - } - $file->ftruncate(); - return [$file, $data]; - } - - /** indiquer que l'application démarre */ - function start(): void { - $this->file->serialize($this->initData()); - } - - /** indiquer le début d'une action */ - function action(?string $title, ?int $maxSteps=null): void { - [$file, $data] = $this->willWrite(); - $file->serialize(self::merge($data, [ - "action" => $title, - "action_date_start" => new DateTime(), - "action_max_step" => $maxSteps, - "action_current_step" => 0, - ])); - } - - /** indiquer qu'une étape est franchie dans l'action en cours */ - function step(int $nbSteps=1): void { - [$file, $data] = $this->willWrite(); - $file->serialize(self::merge($data, [ - "action_date_step" => new DateTime(), - "action_current_step" => $data["action_current_step"] + $nbSteps, - ])); - } - - /** indiquer que l'application s'arrête */ - function stop(): void { - [$file, $data] = $this->willWrite(); - $file->serialize(self::merge($data, [ - "date_stop" => new DateTime(), - ])); - } - - /** après l'arrêt de l'application, mettre à jour le code de retour */ - function stopped(int $exitcode): void { - [$file, $data] = $this->willWrite(); - $file->serialize(self::merge($data, [ - "exitcode" => $exitcode, - ])); - } - - function getLockFile(?string $name=null, ?string $title=null): LockFile { - $ext = self::LOCK_EXT; - if ($name !== null) $ext = ".$name$ext"; - $file = path::ensure_ext($this->file->getFile(), $ext, self::RUN_EXT); - $name = str::join("/", [$this->name, $name]); - return new LockFile($file, $name, $title); - } -} diff --git a/php/src/file/csv/AbstractBuilder.php b/php/src/file/csv/AbstractBuilder.php new file mode 100644 index 0000000..3c0a586 --- /dev/null +++ b/php/src/file/csv/AbstractBuilder.php @@ -0,0 +1,162 @@ +schema = $params["schema"] ?? static::SCHEMA; + $this->headers = $params["headers"] ?? static::HEADERS; + $rows = $params["rows"] ?? null; + if (is_callable($rows)) $rows = $rows(); + $this->rows = $rows; + $cookFunc = $params["cook_func"] ?? null; + $cookCtx = $cookArgs = null; + if ($cookFunc !== null) { + func::ensure_func($cookFunc, $this, $cookArgs); + $cookCtx = func::_prepare($cookFunc); + } + $this->cookCtx = $cookCtx; + $this->cookArgs = $cookArgs; + $this->output = $params["output"] ?? static::OUTPUT; + $maxMemory = $params["max_memory"] ?? null; + $throwOnError = $params["throw_on_error"] ?? null; + parent::__construct($maxMemory, $throwOnError); + } + + protected ?array $schema; + + protected ?array $headers; + + protected ?iterable $rows; + + protected ?string $output; + + protected ?array $cookCtx; + + protected ?array $cookArgs; + + protected function ensureHeaders(?array $row=null): void { + if ($this->headers !== null) return; + if ($this->schema === null) $headers = null; + else $headers = array_keys($this->schema); + if ($headers === null && $row !== null) $headers = array_keys($row); + $this->headers = $headers; + } + + protected abstract function _write(array $row): void; + + protected bool $wroteHeaders = false; + + function writeHeaders(?array $headers=null): void { + if ($this->wroteHeaders) return; + if ($headers !== null) $this->headers = $headers; + else $this->ensureHeaders(); + if ($this->headers !== null) $this->_write($this->headers); + $this->wroteHeaders = true; + } + + protected function cookRow(?array $row): ?array { + if ($this->cookCtx !== null) { + $args = cl::merge([$row], $this->cookArgs); + $row = func::_call($this->cookCtx, $args); + } + if ($row !== null) { + foreach ($row as &$value) { + # formatter les dates + if ($value instanceof DateTime) { + $value = $value->format(); + } elseif ($value instanceof DateTimeInterface) { + $value = DateTime::with($value)->format(); + } + }; unset($value); + } + return $row; + } + + function write(?array $row): void { + $row = $this->cookRow($row); + if ($row === null) return; + $this->writeHeaders(array_keys($row)); + $this->_write($row); + } + + function writeAll(?iterable $rows=null): void { + $unsetRows = false; + if ($rows === null) { + $rows = $this->rows; + $unsetRows = true; + } + if ($rows !== null) { + foreach ($rows as $row) { + $this->write(cl::with($row)); + } + } + if ($unsetRows) $this->rows = null; + } + + abstract protected function _sendContentType(): void; + + protected bool $sentHeaders = false; + + function sendHeaders(): void { + if ($this->sentHeaders) return; + $this->_sendContentType(); + $output = $this->output; + if ($output !== null) { + http::download_as(path::filename($output)); + } + $this->sentHeaders = true; + } + + protected function _build(?iterable $rows=null): void { + $this->writeAll($rows); + $this->writeHeaders(); + } + + abstract protected function _checkOk(): bool; + + protected bool $built = false, $closed = false; + + function build(?iterable $rows=null, bool $close=true): bool { + $ok = true; + if (!$this->built) { + $this->_build($rows); + $this->built = true; + } + if ($close && !$this->closed) { + $ok = $this->_checkOk(); + $this->closed = true; + } + return $ok; + } + + function sendFile(?iterable $rows=null): int { + if (!$this->built) { + $this->_build($rows); + $this->built = true; + } + if (!$this->closed) { + if (!$this->_checkOk()) return 0; + $this->closed = true; + } + $this->sendHeaders(); + return $this->fpassthru(); + } +} diff --git a/php/src/file/csv/AbstractReader.php b/php/src/file/csv/AbstractReader.php new file mode 100644 index 0000000..b52123e --- /dev/null +++ b/php/src/file/csv/AbstractReader.php @@ -0,0 +1,109 @@ +schema = $params["schema"] ?? static::SCHEMA; + $this->headers = $params["headers"] ?? static::HEADERS; + $this->input = $params["input"] ?? static::INPUT; + $this->trim = boolval($params["trim"] ?? static::TRIM); + $this->parseEmptyAsNull = boolval($params["empty_as_null"] ?? static::PARSE_EMPTY_AS_NULL); + $this->parseNumeric = boolval($params["parse_numeric"] ?? static::PARSE_NUMERIC); + $this->parseDate = boolval($params["parse_date"] ?? static::PARSE_DATE); + } + + protected ?array $schema; + + protected ?array $headers; + + protected $input; + + protected bool $trim; + + protected bool $parseEmptyAsNull; + + protected bool $parseNumeric; + + protected bool $parseDate; + + protected int $isrc = 0, $idest = 0; + + protected function cook(array &$row): bool { + if ($this->isrc == 0) { + # ligne d'en-tête + $headers = $this->headers; + if ($headers === null) { + if ($this->schema === null) $headers = null; + else $headers = array_keys($this->schema); + if ($headers === null) $headers = $row; + $this->headers = $headers; + } + return false; + } + A::ensure_size($row, count($this->headers)); + $row = array_combine($this->headers, $row); + return true; + } + + protected function verifixCol(&$col): void { + if ($this->trim && is_string($col)) { + $col = trim($col); + } + if ($this->parseEmptyAsNull && $col === "") { + # valeur vide --> null + $col = null; + } + if (!is_string($col)) return; + if ($this->parseDate) { + if (DateTime::isa_datetime($col, true)) { + # datetime + $col = new DateTime($col); + } elseif (DateTime::isa_date($col, true)) { + # date + $col = new Date($col); + } + if (!is_string($col)) return; + } + $parseNumeric = $this->parseNumeric || substr($col, 0, 1) !== "0"; + if ($parseNumeric) { + $tmp = str_replace(",", ".", $col); + $float = strpos($tmp, ".") !== false; + if (is_numeric($tmp)) { + if ($float) $col = floatval($tmp); + else $col = intval($tmp); + } + } + } +} diff --git a/php/src/file/csv/CsvBuilder.php b/php/src/file/csv/CsvBuilder.php new file mode 100644 index 0000000..c8f6947 --- /dev/null +++ b/php/src/file/csv/CsvBuilder.php @@ -0,0 +1,32 @@ +csvFlavour = csv_flavours::verifix($csvFlavour); + parent::__construct($output, $params); + } + + protected function _write(array $row): void { + $this->fputcsv($row); + } + + function _sendContentType(): void { + http::content_type("text/csv"); + } + + protected function _checkOk(): bool { + $size = $this->ftell(); + if ($size === 0) return false; + $this->rewind(); + return true; + } +} diff --git a/php/src/file/csv/CsvReader.php b/php/src/file/csv/CsvReader.php new file mode 100644 index 0000000..a827d85 --- /dev/null +++ b/php/src/file/csv/CsvReader.php @@ -0,0 +1,38 @@ +csvFlavour = $params["csv_flavour"] ?? null; + $this->inputEncoding = $params["input_encoding"] ?? null; + } + + protected ?string $csvFlavour; + + protected ?string $inputEncoding; + + function getIterator() { + $reader = new FileReader($this->input); + $inputEncoding = $this->inputEncoding; + if ($inputEncoding !== null) { + $reader->appendFilter("convert.iconv.$inputEncoding.utf-8"); + } + $reader->setCsvFlavour($this->csvFlavour); + while (($row = $reader->fgetcsv()) !== null) { + foreach ($row as &$col) { + $this->verifixCol($col); + }; unset($col); + if ($this->cook($row)) { + yield $row; + $this->idest++; + } + $this->isrc++; + } + $reader->close(); + } +} diff --git a/php/src/file/csv/IBuilder.php b/php/src/file/csv/IBuilder.php new file mode 100644 index 0000000..ff2ca94 --- /dev/null +++ b/php/src/file/csv/IBuilder.php @@ -0,0 +1,14 @@ +isExt(".csv")) { + $class = CsvBuilder::class; + } else { + $class = static::class; + if ($builder->isExt(".ods")) { + $params["ss_type"] = "ods"; + } else { + $params["ss_type"] = "xlsx"; + } + } + return new $class($builder->name, $params); + } + + if (is_string($builder)) { + $params["output"] = $builder; + } elseif (is_array($builder)) { + $params = cl::merge($builder, $params); + } elseif ($builder !== null) { + throw ValueException::invalid_type($builder, self::class); + } + + $output = $params["output"] ?? null; + $ssType = null; + if (is_string($output)) { + switch (path::ext($output)) { + case ".csv": + $class = CsvBuilder::class; + break; + case ".ods": + $ssType = "ods"; + break; + case ".xlsx": + default: + $ssType = "xlsx"; + break; + } + } + $params["ss_type"] = $ssType; + if ($class === null) $class = static::class; + return new $class(null, $params); + } +} diff --git a/php/src/file/csv/TAbstractReader.php b/php/src/file/csv/TAbstractReader.php new file mode 100644 index 0000000..dc28910 --- /dev/null +++ b/php/src/file/csv/TAbstractReader.php @@ -0,0 +1,56 @@ +isExt(".csv")) { + $class = CsvReader::class; + } else { + $class = static::class; + if ($reader->isExt(".ods")) { + $params["ss_type"] = "ods"; + } else { + $params["ss_type"] = "xlsx"; + } + } + return new $class($reader->tmpName, $params); + } + + if (is_string($reader)) { + $params["input"] = $reader; + } elseif (is_array($reader)) { + $params = cl::merge($reader, $params); + } elseif ($reader !== null) { + throw ValueException::invalid_type($reader, self::class); + } + + $input = $params["input"] ?? null; + $ssType = null; + if (is_string($input)) { + switch (path::ext($input)) { + case ".csv": + $class = CsvReader::class; + break; + case ".ods": + $ssType = "ods"; + break; + case ".xlsx": + default: + $ssType = "xlsx"; + break; + } + } + $params["ss_type"] = $ssType; + if ($class === null) $class = static::class; + return new $class(null, $params); + } +} diff --git a/php/src/file/csv/csv_flavours.php b/php/src/file/csv/csv_flavours.php index d21bd6e..4bc7bd9 100644 --- a/php/src/file/csv/csv_flavours.php +++ b/php/src/file/csv/csv_flavours.php @@ -3,23 +3,29 @@ namespace nulib\file\csv; use nulib\cl; use nulib\ref\file\csv\ref_csv; +use nulib\str; class csv_flavours { const MAP = [ "oo" => ref_csv::OO_FLAVOUR, "ooffice" => ref_csv::OO_FLAVOUR, - ref_csv::OO_NAME => ref_csv::OO_FLAVOUR, + ref_csv::OOCALC => ref_csv::OO_FLAVOUR, "xl" => ref_csv::XL_FLAVOUR, "excel" => ref_csv::XL_FLAVOUR, - ref_csv::XL_NAME => ref_csv::XL_FLAVOUR, + ref_csv::MSEXCEL => ref_csv::XL_FLAVOUR, + "dumb;" => ref_csv::DUMB_XL_FLAVOUR, + "dumb," => ref_csv::DUMB_OO_FLAVOUR, + "dumb" => ref_csv::DUMB_FLAVOUR, ]; const ENCODINGS = [ ref_csv::OO_FLAVOUR => ref_csv::OO_ENCODING, ref_csv::XL_FLAVOUR => ref_csv::XL_ENCODING, + ref_csv::DUMB_FLAVOUR => ref_csv::DUMB_ENCODING, ]; - static final function verifix(string $flavour): string { + static final function verifix(?string $flavour): ?string { + if ($flavour === null) return null; $lflavour = strtolower($flavour); if (array_key_exists($lflavour, self::MAP)) { $flavour = self::MAP[$lflavour]; @@ -31,8 +37,8 @@ class csv_flavours { } static final function get_name(string $flavour): string { - if ($flavour == ref_csv::OO_FLAVOUR) return ref_csv::OO_NAME; - elseif ($flavour == ref_csv::XL_FLAVOUR) return ref_csv::XL_NAME; + if ($flavour == ref_csv::OO_FLAVOUR) return ref_csv::OOCALC; + elseif ($flavour == ref_csv::XL_FLAVOUR) return ref_csv::MSEXCEL; else return $flavour; } @@ -43,4 +49,11 @@ class csv_flavours { static final function get_encoding(string $flavour): ?string { return cl::get(self::ENCODINGS, $flavour); } + + static final function is_dumb(string $flavour, ?string &$sep): bool { + if (!str::del_prefix($flavour, "xxx")) return false; + $sep = $flavour; + if (!$sep) $sep = ";"; + return true; + } } diff --git a/php/src/file/web/Upload.php b/php/src/file/web/Upload.php index bcafcb9..5e49584 100644 --- a/php/src/file/web/Upload.php +++ b/php/src/file/web/Upload.php @@ -1,7 +1,9 @@ error === UPLOAD_ERR_OK; } + /** + * retourner true si le nom du fichier a une des extensions de $exts + * + * @param string|array $exts une ou plusieurs extensions qui sont vérifiées + */ + function isExt($exts): bool { + if ($exts === null) return false; + $ext = path::ext($this->name); + $exts = cl::with($exts); + return in_array($ext, $exts); + } + /** @var ?string chemin du fichier, s'il a été déplacé */ protected $file; diff --git a/php/src/os/path.php b/php/src/os/path.php index 8a609c0..c02fa30 100644 --- a/php/src/os/path.php +++ b/php/src/os/path.php @@ -156,7 +156,11 @@ class path { return $basename; } - /** obtenir l'extension du fichier. l'extension est retournée avec le '.' */ + /** + * obtenir l'extension du fichier. l'extension est retournée avec le '.' + * + * si le fichier n'a pas d'extension, retourner une chaine vide + */ static final function ext($path): ?string { if ($path === null || $path === false) return null; $ext = self::filename($path); diff --git a/php/src/os/proc/AbstractCmd.php b/php/src/os/proc/AbstractCmd.php new file mode 100644 index 0000000..51ddd35 --- /dev/null +++ b/php/src/os/proc/AbstractCmd.php @@ -0,0 +1,201 @@ +needsStdin = true; + $this->needsTty = true; + $this->sources = null; + $this->vars = null; + $this->cmds = []; + } + + function then($cmd, ?string $input=null, ?string $output=null): Cmd { + if ($this instanceof Cmd) { + $this->add($cmd, $input, $output); + return $this; + } else { + return (new Cmd($this))->add($cmd, $input, $output); + } + } + + function or($cmd, ?string $input=null, ?string $output=null): CmdOr { + if ($this instanceof CmdOr) { + $this->add($cmd, $input, $output); + return $this; + } else { + return (new CmdOr($this))->add($cmd, $input, $output); + } + } + + function and($cmd, ?string $input=null, ?string $output=null): CmdAnd { + if ($this instanceof CmdAnd) { + $this->add($cmd, $input, $output); + return $this; + } else { + return (new CmdAnd($this))->add($cmd, $input, $output); + } + } + + function pipe($cmd): CmdPipe { + if ($this instanceof CmdPipe) { + $this->add($cmd); + return $this; + } else { + return new CmdPipe([$this, $cmd]); + } + } + + function isNeedsStdin(): bool { + return $this->needsStdin; + } + + function setNeedsStdin(bool $needsStdin): void { + $this->needsStdin = $needsStdin; + } + + function isNeedsTty(): bool { + return $this->needsTty; + } + + function setNeedsTty(bool $needsTty): void { + $this->needsTty = $needsTty; + } + + function addSource(?string $source, bool $onlyIfExists=true): void { + if ($source === null) return; + if (!$onlyIfExists || file_exists($source)) { + $source = implode(" ", [".", sh::quote($source)]); + $this->sources[] = $source; + } + } + + function getSources(?string $sep=null): ?string { + if ($this->sources === null) return null; + if ($sep === null) $sep = "\n"; + return implode($sep, $this->sources); + } + + function addLiteralVars($vars, ?string $sep=null): void { + if (cv::z($vars)) return; + if (is_array($vars)) { + if ($sep === null) $sep = "\n"; + $vars = implode($sep, $vars); + } + $this->vars[] = strval($vars); + } + + function addVars(?array $vars): void { + if ($vars === null) return; + foreach ($vars as $name => $value) { + $var = []; + if (!is_array($value)) $var[] = "export "; + A::merge($var, [$name, "=", sh::quote($value)]); + $this->vars[] = implode("", $var); + } + } + + function getVars(?string $sep=null): ?string { + if ($this->vars === null) return null; + if ($sep === null) $sep = "\n"; + return implode($sep, $this->vars); + } + + function addPrefix($prefix): void { + $count = count($this->cmds); + if ($count == 0) return; + $cmd =& $this->cmds[$count - 1]; + if ($cmd instanceof ICmd) { + $cmd->addPrefix($prefix); + } elseif (is_array($prefix)) { + $prefix = sh::join($prefix); + $cmd = "$prefix $cmd"; + } else { + $cmd = "$prefix $cmd"; + } + } + + function addRedir(?string $redir, $output=null, bool $append=false, $input=null): void { + $count = count($this->cmds); + if ($count == 0) return; + + if ($output !== null) $output = escapeshellarg($output); + if ($input !== null) $input = escapeshellarg($input); + if ($redir === "default") $redir = null; + $gt = $append? ">>": ">"; + if ($redir === null) { + $redirs = []; + if ($input !== null) $redirs[] = "<$input"; + if ($output !== null) $redirs[] = "$gt$output"; + if ($redirs) $redir = implode(" ", $redir); + } else { + switch ($redir) { + case "outonly": + case "noerr": + if ($output !== null) $redir = "$gt$output 2>/dev/null"; + else $redir = "2>/dev/null"; + break; + case "erronly": + case "noout": + if ($output !== null) $redir = "2$gt$output >/dev/null"; + else $redir = "2>&1 >/dev/null"; + break; + case "both": + case "err2out": + if ($output !== null) $redir = "$gt$output 2>&1"; + else $redir = "2>&1"; + break; + case "none": + case "null": + $redir = ">/dev/null 2>&1"; + break; + } + } + if ($redir !== null) { + $cmd =& $this->cmds[$count - 1]; + if ($cmd instanceof ICmd) { + $cmd->addRedir($redir); + } else { + $cmd = "$cmd $redir"; + } + } + } + + abstract function getCmd(?string $sep=null, bool $exec=false): string; + + function passthru(int &$retcode=null): bool { + passthru($this->getCmd(), $retcode); + return $retcode == 0; + } + + function system(string &$output=null, int &$retcode=null): bool { + $lastLine = system($this->getCmd(), $retcode); + if ($lastLine !== false) $output = $lastLine; + return $retcode == 0; + } + + function exec(array &$output=null, int &$retcode=null): bool { + exec($this->getCmd(), $output, $retcode); + return $retcode == 0; + } + + function fork_exec(int &$retcode=null): bool { + $cmd = $this->getCmd(null, true); + sh::_fork_exec($cmd, $retcode); + return $retcode == 0; + } +} diff --git a/php/src/os/proc/AbstractCmdList.php b/php/src/os/proc/AbstractCmdList.php new file mode 100644 index 0000000..25de171 --- /dev/null +++ b/php/src/os/proc/AbstractCmdList.php @@ -0,0 +1,53 @@ +sep = $sep; + $this->add($cmd, $input, $output); + } + + function addLiteral($cmd): self { + A::append_nn($this->cmds, $cmd); + return $this; + } + + function add($cmd, ?string $input=null, ?string $output=null): self { + if ($cmd !== null) { + if (!($cmd instanceof ICmd)) { + sh::verifix_cmd($cmd, null, $input, $output); + } + $this->cmds[] = $cmd; + } + return $this; + } + + function getCmd(?string $sep=null, bool $exec=false): string { + if ($sep === null) $sep = "\n"; + + $actualCmd = []; + A::append_nn($actualCmd, $this->getSources($sep)); + A::append_nn($actualCmd, $this->getVars($sep)); + + $parts = []; + foreach ($this->cmds as $cmd) { + if ($cmd instanceof ICmd) { + $cmd = "(".$cmd->getCmd($sep).")"; + } + $parts[] = $cmd; + } + if (count($parts) == 1 && $exec) $parts[0] = "exec $parts[0]"; + $actualCmd[] = implode($this->sep ?? $sep, $parts); + + return implode($sep, $actualCmd); + } +} diff --git a/php/src/os/proc/Cmd.php b/php/src/os/proc/Cmd.php new file mode 100644 index 0000000..c5d6634 --- /dev/null +++ b/php/src/os/proc/Cmd.php @@ -0,0 +1,19 @@ +add($command); + } + } + $this->input = $input; + $this->output = $output; + } + + function addLiteral($cmd): self { + A::append_nn($this->cmds, $cmd); + return $this; + } + + function add($cmd): self { + if ($cmd !== null) { + if (!($cmd instanceof ICmd)) { + sh::verifix_cmd($cmd); + } + $this->cmds[] = $cmd; + } + return $this; + } + + function setInput(?string $input=null): self { + $this->input = $input; + return $this; + } + + function setOutput(?string $output=null): self { + $this->output = $output; + return $this; + } + + function getCmd(?string $sep=null, bool $exec=false): string { + if ($sep === null) $sep = "\n"; + + $actualCmd = []; + A::append_nn($actualCmd, $this->getSources($sep)); + A::append_nn($actualCmd, $this->getVars($sep)); + + $parts = []; + foreach ($this->cmds as $cmd) { + if ($cmd instanceof ICmd) { + $cmd = "(".$cmd->getCmd($sep).")"; + } + $parts[] = $cmd; + } + $cmd = implode(" | ", $parts); + + $input = $this->input; + $output = $this->output; + if ($input !== null || $output !== null) { + $parts = []; + if ($input !== null) $parts[] = "<".escapeshellarg($input); + $parts[] = $cmd; + if ($output !== null) $parts[] = ">".escapeshellarg($output); + $cmd = implode(" ", $parts); + } + $actualCmd[] = $cmd; + + return implode($sep, $actualCmd); + } +} diff --git a/php/src/os/proc/ICmd.php b/php/src/os/proc/ICmd.php new file mode 100644 index 0000000..2659f66 --- /dev/null +++ b/php/src/os/proc/ICmd.php @@ -0,0 +1,82 @@ +clone($params); } - /** @var IMessenger */ - protected static $msg; - - /** @var IMessenger[] */ - protected static $stack; - - /** pousser une nouvelle instance avec un nouveau paramétrage sur la pile */ - static function push(?array $params=null) { - self::$stack[] = static::get(); - self::$msg = self::new($params); - } - - /** dépiler la précédente instance */ - static function pop(): IMessenger { - if (self::$stack) $msg = self::$msg = array_pop(self::$stack); - else $msg = self::$msg; - return $msg; - } - static final function __callStatic($name, $args) { $name = str::us2camel($name); call_user_func_array([static::get(), $name], $args); diff --git a/php/src/output/console.php b/php/src/output/console.php new file mode 100644 index 0000000..0ef8c81 --- /dev/null +++ b/php/src/output/console.php @@ -0,0 +1,28 @@ +msgs as $msg) { - $msg->action($content, null, $level); if ($msg instanceof _IMessenger) { $useFunc = true; $untils[] = $msg->_getActionMark(); } + $msg->action($content, null, $level); } if ($useFunc && $func !== null) { try { diff --git a/php/src/output/std/StdMessenger.php b/php/src/output/std/StdMessenger.php index c9cc6b2..0b3134c 100644 --- a/php/src/output/std/StdMessenger.php +++ b/php/src/output/std/StdMessenger.php @@ -306,12 +306,12 @@ class StdMessenger implements _IMessenger { $rprefix2 = str_repeat(" ", mb_strlen($rprefix)); } if ($printContent && $printResult) { + A::ensure_array($content); if ($rcontent) { - A::ensure_array($content); $content[] = ": "; $content[] = $rcontent; } - $lines = $out->getLines(false, $content); + $lines = $out->getLines(false, ...$content); foreach ($lines as $content) { if ($linePrefix !== null) $out->write($linePrefix); $out->iprint($indentLevel, $rprefix, $content); @@ -331,7 +331,9 @@ class StdMessenger implements _IMessenger { $prefix2 = str_repeat(" ", mb_strlen($prefix)); $suffix = null; } - $lines = $out->getLines(false, $content, ":"); + A::ensure_array($content); + $content[] = ":"; + $lines = $out->getLines(false, ...$content); foreach ($lines as $content) { if ($linePrefix !== null) $out->write($linePrefix); $out->iprint($indentLevel, $prefix, $content, $suffix); @@ -399,7 +401,9 @@ class StdMessenger implements _IMessenger { $valueContent[] = $value; } } - $content = $valueContent; + if ($valueContent === null) $content = null; + elseif (count($valueContent) == 1) $content = $valueContent[0]; + else $content = $valueContent; } elseif ($content instanceof Throwable || $content instanceof ExceptionShadow) { $exceptions[] = $content; $content = null; @@ -416,7 +420,13 @@ class StdMessenger implements _IMessenger { $showTraceback = $this->checkLevel($level1); foreach ($exceptions as $exception) { # tout d'abord userMessage - $userMessage = UserException::get_user_message($exception); + if ($exception instanceof UserException) { + $userMessage = UserException::get_user_message($exception); + $showSummary = true; + } else { + $userMessage = UserException::get_summary($exception); + $showSummary = false; + } if ($userMessage !== null && $showContent) { if ($printActions) { $this->printActions(); $printActions = false; } $this->_printGeneric($linePrefix, $level, $type, $userMessage, $indentLevel, $out); @@ -424,9 +434,11 @@ class StdMessenger implements _IMessenger { # puis summary et traceback if ($showTraceback) { if ($printActions) { $this->printActions(); $printActions = false; } - $summary = UserException::get_summary($exception); + if ($showSummary) { + $summary = UserException::get_summary($exception); + $this->_printGeneric($linePrefix, $level1, $type, $summary, $indentLevel, $out); + } $traceback = UserException::get_traceback($exception); - $this->_printGeneric($linePrefix, $level1, $type, $summary, $indentLevel, $out); $this->_printGeneric($linePrefix, $level1, $type, $traceback, $indentLevel, $out); } } diff --git a/php/src/output/std/StdOutput.php b/php/src/output/std/StdOutput.php index cfe4df0..c30c466 100644 --- a/php/src/output/std/StdOutput.php +++ b/php/src/output/std/StdOutput.php @@ -4,7 +4,7 @@ namespace nulib\output\std; use Exception; use nulib\cl; use nulib\file\Stream; -use nulib\php\content\content; +use nulib\php\content\c; /** * Class StdOutput: affichage sur STDOUT, STDERR ou dans un fichier quelconque @@ -187,9 +187,9 @@ class StdOutput { } function getLines(bool $withNl, ...$values): array { - $values = content::flatten($values); + $values = c::resolve($values, null, false); if (!$values) return []; - $text = implode("", $values); + $text = c::to_string($values, false); if ($text === "") return [""]; $text = $this->filterContent($text); if (!$this->color) $text = $this->filterColors($text); diff --git a/php/src/php/content/IStaticContent.php b/php/src/php/content/IStaticContent.php deleted file mode 100644 index 7d1eec9..0000000 --- a/php/src/php/content/IStaticContent.php +++ /dev/null @@ -1,9 +0,0 @@ -content = $content; + } + + protected IContent $content; + + function print(): void { + $content = $this->content->getContent(); + c::write($content); + } + + function __call($name, $args) { + $content = func::call([$this->content, $name], ...$args); + c::write($content); + } +} diff --git a/php/src/php/content/README.md b/php/src/php/content/README.md new file mode 100644 index 0000000..0e7eb5a --- /dev/null +++ b/php/src/php/content/README.md @@ -0,0 +1,77 @@ +# nulib\php\content + +un contenu (ou "content") est une liste de valeurs, avec une syntaxe pour que +certains éléments soient dynamiquement calculés. + +le contenu final est résolu selon les règles suivantes: +- Si le contenu n'est pas un tableau: + - une chaine est quotée avec `htmlspecialchars()` + - un scalaire ou une instance d'objet sont pris tels quels +- Sinon, le contenu doit être un tableau, séquentiel ou associatif, ça n'a pas + d'incidence + - les éléments scalaires ou instance d'objets sont pris tels quels + - les Closure sont appelés dès la résolution, et leur valeur de retour est + considéré comme un contenu *statique* inséré tel quel dans le flux i.e dans + l'exemple suivant $c1 et $c2 sont globalement équivalents: + ~~~php + $closure = function() { ... } + $c1 = [...$before, $closure, ...$after]; + $c2 = [...$before, ...c::q($closure()), ...$after]; + # $c1 == $c2, sauf si $closure() retourne des valeurs qui peuvent être + # considérées comme du contenu dynamique + ~~~ + - les tableaux représentent un traitement dynamique: appel de fonction, + instanciation, etc. le contenu effectif n'est évalué que lors de l'affichage + +Les syntaxes possibles sont: + +`[[], $args...]` +: contenu statique: les valeurs $args... sont insérées dans le flux du contenu + sans modification. c'est la seule façon d'insérer un tableau dans la liste des + valeurs (on peut aussi utiliser une Closure, mais ce n'est pas toujours + possible, notamment si le contenu est une constante) + +`["class_or_function", $args...]` +`[["class_or_function"], $args...]` +`[["function", $args0...], $args1...]` +`[["class", null, $args0...], $args1...]` +: instantiation ou appel de fonction + +`["->method", $args...]` +`[["->method"], $args...]` +`[[null, "method"], $args...]` +`[[null, "method", $args0...], $args1...]` +: appel de méthode sur l'objet contexte spécifié lors de la résolution du contenu + +`[[$object, "method"], $args...]` +`[[$object, "method", $args0...], $args1...]` +: appel de méthode sur l'objet spécifié + +`[["class", "method"], $args...]` +`[["class", "method", $args0...], $args1...]` +: appel de méthode statique de la classe spécifiée + +Le fait de transformer un contenu en une liste de valeurs statiques s'appelle +la résolution. la résolution se fait par rapport à un objet contexte, qui est +utilisé lors des appels de méthodes. + +Lors des appels de fonctions ou des instanciations, les $arguments sont tous des +contenus: +- une valeur scalaire ou une instance est passée inchangée +- un tableau est traité comme un contenu avec les règles ci-dessus + +## Affichage d'un contenu + +Deux interfaces sont utilisées pour modéliser un élément de contenu à afficher: +- IContent: objet capable de produire du contenu +- IPrintable: objet capable d'afficher un contenu + +Tous les autres éléments de contenus sont transformés en string avant affichage. +Un système de formatters permet de définir des fonctions ou méthodes à utiliser +pour formatter des objets de certains types. + +Lors de l'affichage du contenu, deux éléments contigûs $a et $b sont affichés +séparés par un espace si $a se termine par un mot (éventuellement terminé par +un point '.') et $b commence par un mot. + +-*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary \ No newline at end of file diff --git a/php/src/php/content/c.php b/php/src/php/content/c.php new file mode 100644 index 0000000..d2ff6ec --- /dev/null +++ b/php/src/php/content/c.php @@ -0,0 +1,178 @@ + $svalue) { + if ($skey === $sindex) { + $sindex++; + if ($seq) { + $dest[] = $svalue; + } else { + # la première sous-clé séquentielle est ajoutée avec la clé du + # merge statique + $dest[$key] = $svalue; + $seq = true; + } + } else { + $dest[$skey] = $svalue; + } + } + + } + + /** résoudre le contenu, et retourner la liste des valeurs */ + static final function resolve($content, $object_or_class=null, bool $quote=true, ?array &$dest=null): array { + if ($dest === null) $dest = []; + $content = $quote? self::q($content): self::nq($content); + $index = 0; + foreach ($content as $key => $value) { + if ($key === $index) { + $index++; + $seq = true; + } else { + $seq = false; + } + if ($value instanceof Closure) { + # contenu dynamique: le contenu est la valeur de retour de la fonction + # ce contenu est rajouté à la suite après avoir été quoté avec self::q() + $func = $value; + func::ensure_func($func, $object_or_class, $args); + $values = self::q(func::call($func, ...$args)); + self::add_static_content($dest, $values, $key, $seq); + continue; + } + if (is_array($value)) { + # contenu dynamique + if (count($value) == 0) continue; + $func = cl::first($value); + $args = array_slice($value, 1); + if ($func === []) { + # merge statique + self::add_static_content($dest, $args, $key, $seq); + continue; + } else { + # chaque argument de la fonction à appeler est aussi un contenu + foreach ($args as &$arg) { + $array = is_array($arg); + $arg = self::resolve($arg, $object_or_class, false); + if (!$array) $arg = $arg[0]; + }; unset($arg); + if (func::is_static($func)) { + func::ensure_func($func, $object_or_class, $args); + $value = func::call($func, ...$args); + } elseif (func::is_class($func)) { + func::fix_class_args($func, $args); + $value = func::cons($func, ...$args); + } else { + func::ensure_func($func, $object_or_class, $args); + $value = func::call($func, ...$args); + } + } + } + if ($seq) $dest[] = $value; + else $dest[$key] = $value; + } + return $dest; + } + const resolve = [self::class, "resolve"]; + + private static final function wend(?string $value): bool { + return $value !== null && preg_match('/(\w|\w\.)$/', $value); + } + private static final function startw(?string $value): bool { + return $value !== null && preg_match('/^\w/', $value); + } + + private static final function to_values($content, ?array &$values=null): void { + $pvalue = cl::last($values); + $wend = self::wend($pvalue); + foreach ($content as $value) { + if ($value === null || $value === false) { + continue; + } elseif ($value instanceof IContent) { + self::to_values($value->getContent(), $values); + continue; + } elseif ($value instanceof IPrintable) { + ob_start(null, 0, PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE); + $value->print(); + $value = ob_get_clean(); + } else { + $value = strval($value); + #XXX rendre paramétrable le formatage de $value + } + if ($value !== "") { + $startw = self::startw($value); + if ($wend && $startw) $values[] = " "; + $values[] = $value; + $wend = self::wend($value); + } + } + } + + /** écrire le contenu sur la resource spécifiée, qui vaut STDOUT par défaut */ + static final function write($content, $fd=null, bool $resolve=true): void { + if ($resolve) $content = self::resolve($content); + $wend = false; + foreach ($content as $value) { + if ($value === null || $value === false) { + continue; + } elseif ($value instanceof IPrintable) { + if ($fd === null) { + $value->print(); + } else { + ob_start(null, 0, PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE); + $value->print(); + fwrite($fd, ob_get_clean()); + } + $wend = false; + continue; + } elseif ($value instanceof IContent) { + $values = []; + self::to_values($content, $values); + $value = implode("", $values); + } else { + $value = strval($value); + #XXX rendre paramétrable le formattage de $value + } + $startw = self::startw($value); + if (!$wend && !$startw) $value = " $value"; + if ($fd === null) echo $value; + else fwrite($fd, $value); + $wend = self::wend($value); + } + } + + /** retourner le contenu sous forme de chaine */ + static final function to_string($content, bool $resolve=true): string { + if ($resolve) $content = self::resolve($content); + $values = []; + self::to_values($content, $values); + return implode("", $values); + } +} diff --git a/php/src/php/content/content.php b/php/src/php/content/content.php deleted file mode 100644 index 6114c8c..0000000 --- a/php/src/php/content/content.php +++ /dev/null @@ -1,147 +0,0 @@ -getContent(); - } elseif ($contents instanceof IPrintable) { - ob_start(null, 0, PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE); - $contents->print(); - $dest[] = ob_get_clean(); - return $dest; - } elseif (is_scalar($contents)) { - $dest[] = $contents; - return $dest; - } elseif (!is_iterable($contents)) { - $dest[] = strval($contents); - return $dest; - } - # sinon parcourir $contents - foreach ($contents as $value) { - if ($value === null) continue; - elseif (is_scalar($value)) $dest[] = $value; - else self::flatten($value, $dest); - } - return $dest; - } - - /** - * résoudre tout le contenu statique: il ne restera plus qu'une liste de - * chaines ou d'instances de IContent, IPrintable ou des objets quelconques - */ - static function resolve_static($contents, $dest_class=null, bool $quote=true, ?array &$dest=null): array { - if ($dest === null) $dest = []; - if ($contents === null) return $dest; - if (is_string($contents)) { - if ($quote) $contents = htmlspecialchars($contents); - $dest[] = $contents; - return $dest; - } elseif ($contents instanceof IStaticContent) { - $contents = $contents->getContent(); - $dest = cl::merge($dest, self::resolve_static($contents, $dest_class, false)); - return $dest; - } elseif ($contents instanceof IContent) { - $dest[] = $contents; - return $dest; - } elseif ($contents instanceof IPrintable) { - $dest[] = $contents; - return $dest; - } elseif (!is_iterable($contents)) { - $dest[] = $contents; - return $dest; - } - foreach ($contents as $content) { - if ($content === null) continue; - elseif ($contents instanceof IStaticContent) { - $contents = $content->getContent(); - $dest = cl::merge($dest, self::resolve_static($contents, $dest_class, false)); - } elseif ($contents instanceof IContent) $dest[] = $content; - elseif ($contents instanceof IPrintable) $dest[] = $content; - elseif (!is_array($content)) $dest[] = $content; - else { - # ici, $content est soit: - # - un appel de fonction ou méthode statique d'une des formes suivantes: - # - [class, method, ...args] - # - [null, method, ...args] - # - [[class, method], ...args] - # - [[null, method], ...args] - # - ou une définition d'objet d'une des formes suivantes - # - [class, ...args] - # - [[class], ...args] - $func_isa_class = false; - if (array_key_exists(0, $content) && is_array($content[0])) { - # $content est de la forme [func_or_class, ...args] - $func = $content[0]; - $args = array_slice($content, 1); - if (func::is_static($func)) { - func::ensure_func($func, $dest_class, $args); - } elseif (func::is_class($func)) { - $func_isa_class = true; - func::fix_class_args($func, $args); - } else { - func::ensure_func($func, $dest_class, $args); - } - } else { - # $content est de la forme $func - $func = $content; - if (func::is_static($func)) { - func::ensure_func($func, $dest_class, $args); - } elseif (func::is_class($func)) { - $func_isa_class = true; - func::fix_class_args($func, $args); - } else { - func::ensure_func($func, $dest_class, $args); - } - } - # chaque argument est un contenu statique - foreach ($args as &$arg) { - $array = is_array($arg); - $arg = self::resolve_static($arg, $dest_class, false); - $arg = self::flatten($arg); - if ($array) { - $arg = [implode("", $arg)]; - } else { - switch (count($arg)) { - case 0: $arg = null; break; - case 1: $arg = $arg[0]; break; - default: $arg = implode("", $arg); - } - } - }; unset($arg); - # puis appeler la fonction - if ($func_isa_class) $content = func::cons($func, ...$args); - else $content = func::call($func, ...$args); - $dest[] = $content; - } - } - return $dest; - } - - static function to_string($contents, $dest_class=null, bool $quote=true): string { - return implode("", self::flatten(self::resolve_static($contents, $dest_class, $quote))); - } -} diff --git a/php/src/php/func.php b/php/src/php/func.php index 0df602a..e2ba76a 100644 --- a/php/src/php/func.php +++ b/php/src/php/func.php @@ -4,7 +4,6 @@ namespace nulib\php; use Closure; use nulib\cl; use nulib\ref\php\ref_func; -use nulib\schema\Schema; use nulib\ValueException; use ReflectionClass; use ReflectionFunction; @@ -418,6 +417,18 @@ class func { return false; } + /** + * Comme {@link check_class()} mais lance une exception si la classe est + * invalide + * + * @throws ValueException si $class n'est pas une classe valide + */ + static final function ensure_class(&$class, &$args=null): void { + if (!self::check_class($class, $args)) { + throw ValueException::invalid_type($class, "class"); + } + } + /** * Instancier la classe avec les arguments spécifiés. * Adapter $args en fonction du nombre réel d'arguments du constructeur diff --git a/php/src/php/iter/AbstractIterator.php b/php/src/php/iter/AbstractIterator.php index 3e882ef..1e9f991 100644 --- a/php/src/php/iter/AbstractIterator.php +++ b/php/src/php/iter/AbstractIterator.php @@ -15,18 +15,18 @@ abstract class AbstractIterator implements Iterator, ICloseable { * * les exceptions lancées par cette méthode sont ignorées. */ - protected function _setup(): void {} + protected function iter_setup(): void {} /** * lancer un traitement avant de commencer l'itération. * - * cette méthode est appelée après {@link _setup()} et l'objet est garanti + * cette méthode est appelée après {@link iter_setup()} et l'objet est garanti * d'être dans un état valide. * * cette méthode est prévue pour être surchargée par l'utilisateur, mais il * doit gérer lui-même les exceptions éventuelles. */ - protected function beforeIter() {} + protected function iter_beforeStart() {} /** * retourner le prochain élément. @@ -37,35 +37,35 @@ abstract class AbstractIterator implements Iterator, ICloseable { * * @throws NoMoreDataException */ - abstract protected function _next(&$key); + abstract protected function iter_next(&$key); /** - * modifier l'élement retourné par {@link _next()} avant de le retourner. + * modifier l'élement retourné par {@link iter_next()} avant de le retourner. * * * cette méthode est prévue pour être surchargée par l'utilisateur, mais il * doit gérer lui-même les exceptions éventuelles. */ - protected function cook(&$item) {} + protected function iter_cook(&$item): void {} /** * lancer un traitement avant de terminer l'itération et de libérer les * resources * - * cette méthode est appelée avant {@link _teardown()} et l'objet est garanti - * d'être dans un état valide. + * cette méthode est appelée avant {@link iter_teardown()} et l'objet est + * garanti d'être dans un état valide. * * cette méthode est prévue pour être surchargée par l'utilisateur, mais il * doit gérer lui-même les exceptions éventuelles. */ - protected function beforeClose(): void {} + protected function iter_beforeClose(): void {} /** * libérer les ressources allouées. * * les exceptions lancées par cette méthode sont ignorées. */ - protected function _teardown(): void {} + protected function iter_teardown(): void {} function close(): void { $this->rewind(); @@ -75,6 +75,11 @@ abstract class AbstractIterator implements Iterator, ICloseable { # Implémentation par défaut private $setup = false; + + protected function _hasIteratorBeenSetup(): bool { + return $this->setup; + } + private $valid = false; private $toredown = true; @@ -94,17 +99,17 @@ abstract class AbstractIterator implements Iterator, ICloseable { if ($this->toredown) return; $this->valid = false; try { - $item = $this->_next($key); + $item = $this->iter_next($key); } catch (NoMoreDataException $e) { - $this->beforeClose(); + $this->iter_beforeClose(); try { - $this->_teardown(); + $this->iter_teardown(); } catch (Exception $e) { } $this->toredown = true; return; } - $this->cook($item); + $this->iter_cook($item); $this->item = $item; if ($key !== null) { $this->key = $key; @@ -118,9 +123,9 @@ abstract class AbstractIterator implements Iterator, ICloseable { function rewind(): void { if ($this->setup) { if (!$this->toredown) { - $this->beforeClose(); + $this->iter_beforeClose(); try { - $this->_teardown(); + $this->iter_teardown(); } catch (Exception $e) { } } @@ -136,12 +141,12 @@ abstract class AbstractIterator implements Iterator, ICloseable { function valid(): bool { if (!$this->setup) { try { - $this->_setup(); + $this->iter_setup(); } catch (Exception $e) { } $this->setup = true; $this->toredown = false; - $this->beforeIter(); + $this->iter_beforeStart(); $this->next(); } return $this->valid; diff --git a/php/src/php/time/Date.php b/php/src/php/time/Date.php index ac00e00..3556ed4 100644 --- a/php/src/php/time/Date.php +++ b/php/src/php/time/Date.php @@ -11,7 +11,7 @@ class Date extends DateTime { function __construct($datetime="now", DateTimeZone $timezone=null) { parent::__construct($datetime, $timezone); - $this->setTime(0, 0, 0, 0); + $this->setTime(0, 0); } function format($format=self::DEFAULT_FORMAT): string { diff --git a/php/src/php/time/DateTime.php b/php/src/php/time/DateTime.php index 3c9572b..9238b97 100644 --- a/php/src/php/time/DateTime.php +++ b/php/src/php/time/DateTime.php @@ -34,6 +34,32 @@ class DateTime extends \DateTime { const DMYHIS_PATTERN = '/^(\d+)\/(\d+)(?:\/(\d+))? +(\d+)[h:.](\d+)(?:[:.](\d+))?$/'; const YMDHISZ_PATTERN = '/^((?:\d{2})?\d{2})(\d{2})(\d{2})[tT](\d{2})(\d{2})(\d{2})?([zZ])?$/'; + static function isa($datetime): bool { + if ($datetime === null) return false; + if ($datetime instanceof DateTimeInterface) return true; + if (!is_int($datetime) && !is_string($datetime)) return false; + return preg_match(self::DMY_PATTERN, $datetime) || + preg_match(self::YMD_PATTERN, $datetime) || + preg_match(self::DMYHIS_PATTERN, $datetime) || + preg_match(self::YMDHISZ_PATTERN, $datetime); + } + + static function isa_datetime($datetime, bool $frOnly=false): bool { + if ($datetime === null) return false; + if ($datetime instanceof DateTimeInterface) return true; + if (!is_int($datetime) && !is_string($datetime)) return false; + return preg_match(self::DMYHIS_PATTERN, $datetime) || + (!$frOnly && preg_match(self::YMDHISZ_PATTERN, $datetime)); + } + + static function isa_date($date, bool $frOnly=false): bool { + if ($date === null) return false; + if ($date instanceof DateTimeInterface) return true; + if (!is_int($date) && !is_string($date)) return false; + return preg_match(self::DMY_PATTERN, $date) || + (!$frOnly && preg_match(self::YMD_PATTERN, $date)); + } + static function _YmdHMSZ_format(\DateTime $datetime): string { $YmdHMS = $datetime->format("Ymd\\THis"); $Z = $datetime->format("P"); @@ -113,6 +139,7 @@ class DateTime extends \DateTime { } function __construct($datetime="now", DateTimeZone $timezone=null) { + $datetime ??= "now"; if ($datetime instanceof \DateTimeInterface) { if ($timezone === null) $timezone = $datetime->getTimezone(); parent::__construct(); @@ -174,6 +201,51 @@ class DateTime extends \DateTime { return \DateTime::format($format); } + /** + * modifier cet objet pour que l'heure soit à 00:00:00 ce qui le rend propice + * à l'utilisation comme borne inférieure d'une période + */ + function wrapStartOfDay(): self { + $this->setTime(0, 0); + return $this; + } + + /** + * modifier cet objet pour que l'heure soit à 23:59:59.999999 ce qui le rend + * propice à l'utilisation comme borne supérieure d'une période + */ + function wrapEndOfDay(): self { + $this->setTime(23, 59, 59, 999999); + return $this; + } + + function getPrevDay(int $nbDays=1, bool $skipWeekend=false): self { + if ($nbDays == 1 && $skipWeekend && $this->wday == 1) { + $nbdays = 3; + } + return static::with($this->sub(new \DateInterval("P${nbDays}D"))); + } + + function getNextDay(int $nbDays=1, bool $skipWeekend=false): self { + if ($nbDays == 1 && $skipWeekend) { + $wday = $this->wday; + if ($wday > 5) $nbDays = 8 - $this->wday; + } + return static::with($this->add(new \DateInterval("P${nbDays}D"))); + } + + function getElapsedAt(?DateTime $now=null, ?int $resolution=null): string { + return Elapsed::format_at($this, $now, $resolution); + } + + function getElapsedSince(?DateTime $now=null, ?int $resolution=null): string { + return Elapsed::format_since($this, $now, $resolution); + } + + function getElapsedDelay(?DateTime $now=null, ?int $resolution=null): string { + return Elapsed::format_delay($this, $now, $resolution); + } + function __toString(): string { return $this->format(); } diff --git a/php/src/php/time/Elapsed.php b/php/src/php/time/Elapsed.php new file mode 100644 index 0000000..37f22c6 --- /dev/null +++ b/php/src/php/time/Elapsed.php @@ -0,0 +1,174 @@ + 0) { + if ($text !== "") $text .= " "; + $text .= sprintf("%d jour", $d); + if ($d > 1) $text .= "s"; + } + if ($h > 0) { + if ($text !== "") $text .= " "; + $text .= sprintf("%d heure", $h); + if ($h > 1) $text .= "s"; + } + if ($m > 0) { + if ($text !== "") $text .= " "; + $text .= sprintf("%d minute", $m); + if ($m > 1) $text .= "s"; + } + return $text; + } + + private static function format_seconds(int $seconds, string $prefix, ?string $zero): string { + $seconds = abs($seconds); + + if ($zero === null) $zero = "maintenant"; + if ($seconds == 0) return $zero; + + if ($seconds <= 3) { + if ($prefix !== "") $prefix .= " "; + return "${prefix}quelques secondes"; + } + + if ($seconds < 60) { + if ($prefix !== "") $prefix .= " "; + return sprintf("${prefix}%d secondes", $seconds); + } + + $oneDay = 60 * 60 * 24; + $oneHour = 60 * 60; + $oneMinute = 60; + $rs = $seconds; + $d = intdiv($rs, $oneDay); $rs = $rs % $oneDay; + $h = intdiv($rs, $oneHour); $rs = $rs % $oneHour; + $m = intdiv($rs, $oneMinute); + return self::format_generic($prefix, $d, $h, $m); + } + + private static function format_minutes(int $seconds, string $prefix, ?string $zero): string { + $minutes = intdiv(abs($seconds), 60); + + if ($zero === null) $zero = "maintenant"; + if ($minutes == 0) return $zero; + + if ($minutes <= 3) { + if ($prefix !== "") $prefix .= " "; + return "${prefix}quelques minutes"; + } + + $oneDay = 60 * 24; + $oneHour = 60; + $rs = $minutes; + $d = intdiv($rs, $oneDay); $rs = $rs % $oneDay; + $h = intdiv($rs, $oneHour); $rs = $rs % $oneHour; + $m = $rs; + return self::format_generic($prefix, $d, $h, $m); + } + + private static function format_hours(int $seconds, string $prefix, ?string $zero): string { + $minutes = intdiv(abs($seconds), 60); + + if ($zero === null) $zero = "maintenant"; + if ($minutes == 0) return $zero; + + if ($minutes < 60) { + if ($prefix !== "") $prefix .= " "; + return "${prefix}moins d'une heure"; + } elseif ($minutes < 120) { + if ($prefix !== "") $prefix .= " "; + return "${prefix}moins de deux heures"; + } + + $oneDay = 60 * 24; + $oneHour = 60; + $rs = $minutes; + $d = intdiv($rs, $oneDay); $rs = $rs % $oneDay; + $h = intdiv($rs, $oneHour); + return self::format_generic($prefix, $d, $h, 0); + } + + private static function format_days(int $seconds, string $prefix, ?string $zero): string { + $hours = intdiv(abs($seconds), 60 * 60); + + if ($zero === null) $zero = "aujourd'hui"; + if ($hours < 24) return $zero; + + $oneDay = 24; + $rs = $hours; + $d = intdiv($rs, $oneDay); + return self::format_generic($prefix, $d, 0, 0); + } + + static function format_at(DateTime $start, ?DateTime $now=null, ?int $resolution=null): string { + $now ??= new DateTime(); + $seconds = $now->getTimestamp() - $start->getTimestamp(); + return (new self($seconds, $resolution))->formatAt(); + } + + static function format_since(DateTime $start, ?DateTime $now=null, ?int $resolution=null): string { + $now ??= new DateTime(); + $seconds = $now->getTimestamp() - $start->getTimestamp(); + return (new self($seconds, $resolution))->formatSince(); + } + + static function format_delay(DateTime $start, ?DateTime $now=null, ?int $resolution=null): string { + $now ??= new DateTime(); + $seconds = $now->getTimestamp() - $start->getTimestamp(); + return (new self($seconds, $resolution))->formatDelay(); + } + + function __construct(int $seconds, ?int $resolution=null) { + $resolution ??= static::DEFAULT_RESOLUTION; + if ($resolution < self::RESOLUTION_SECONDS) $resolution = self::RESOLUTION_SECONDS; + elseif ($resolution > self::RESOLUTION_DAYS) $resolution = self::RESOLUTION_DAYS; + $this->seconds = $seconds; + $this->resolution = $resolution; + } + + /** @var int */ + private $seconds; + + /** @var int */ + private $resolution; + + function formatAt(): string { + $seconds = $this->seconds; + if ($seconds < 0) return self::format($seconds, $this->resolution, "dans"); + else return self::format($seconds, $this->resolution, "il y a"); + } + + function formatSince(): string { + $seconds = $this->seconds; + if ($seconds < 0) return self::format(-$seconds, $this->resolution, "dans"); + else return self::format($seconds, $this->resolution, "depuis"); + } + + function formatDelay(): string { + return self::format($this->seconds, $this->resolution, "", "immédiat"); + } +} diff --git a/php/src/ref/file/csv/ref_csv.php b/php/src/ref/file/csv/ref_csv.php index 9f40aa4..22fcb9c 100644 --- a/php/src/ref/file/csv/ref_csv.php +++ b/php/src/ref/file/csv/ref_csv.php @@ -10,11 +10,23 @@ class ref_csv { const CP1252 = "cp1252"; const LATIN1 = "iso-8859-1"; - const OO_NAME = "oocalc"; + const OOCALC = "oocalc"; const OO_FLAVOUR = ",\"\\"; const OO_ENCODING = self::UTF8; - - const XL_NAME = "msexcel"; + + const MSEXCEL = "msexcel"; const XL_FLAVOUR = ";\"\\"; const XL_ENCODING = self::CP1252; + + const DUMBOO = "xxx,"; + const DUMB_OO_FLAVOUR = "xxx,"; + const DUMB_OO_ENCODING = self::UTF8; + + const DUMBXL = "xxx;"; + const DUMB_XL_FLAVOUR = "xxx;"; + const DUMB_XL_ENCODING = self::UTF8; + + const DUMB = self::DUMBXL; + const DUMB_FLAVOUR = self::DUMB_XL_FLAVOUR; + const DUMB_ENCODING = self::DUMB_XL_ENCODING; } diff --git a/php/src/str.php b/php/src/str.php index 3a8353b..e703786 100644 --- a/php/src/str.php +++ b/php/src/str.php @@ -242,6 +242,22 @@ class str { return true; } + /** + * ajouter $sep$prefix$text$suffix à $s si $text est non vide + * + * NB: ne rajouter $sep que si $s est non vide + */ + static final function addsep(?string &$s, ?string $sep, ?string $text, ?string $prefix=null, ?string $suffix=null): void { + if (!$text) return; + if ($s) $s .= $sep; + $s .= $prefix.$text.$suffix; + } + + /** si $text est non vide, ajouter $prefix$text$suffix à $s en séparant la valeur avec un espace */ + static final function add(?string &$s, ?string $text, ?string $prefix=null, ?string $suffix=null): void { + self::addsep($s, " ", $text, $prefix, $suffix); + } + /** splitter $s en deux chaines séparées par $sep */ static final function split_pair(?string $s, string $sep=":"): array { if ($s === null) return [null, null]; @@ -319,7 +335,7 @@ class str { const CAMEL_PATTERN0 = '/([A-Z0-9]+)$/A'; const CAMEL_PATTERN1 = '/([A-Z0-9]+)[A-Z]/A'; - const CAMEL_PATTERN2 = '/([^A-Z]+)/A'; + const CAMEL_PATTERN2 = '/([A-Z]?[^A-Z]+)/A'; const CAMEL_PATTERN3 = '/([A-Z][^A-Z]*)/A'; /** @@ -338,25 +354,30 @@ class str { */ static final function camel2us(?string $camel, bool $upper=false, string $delimiter="_"): ?string { if ($camel === null || $camel === "") return $camel; + $prefix = null; + if (preg_match('/^(_+)(.*)/', $camel, $ms)) { + $prefix = $ms[1]; + $camel = $ms[2]; + } $parts = []; - if (preg_match(self::CAMEL_PATTERN0, $camel, $vs, PREG_OFFSET_CAPTURE)) { + if (preg_match(self::CAMEL_PATTERN0, $camel, $ms, PREG_OFFSET_CAPTURE)) { # que des majuscules - } elseif (preg_match(self::CAMEL_PATTERN1, $camel, $vs, PREG_OFFSET_CAPTURE)) { + } elseif (preg_match(self::CAMEL_PATTERN1, $camel, $ms, PREG_OFFSET_CAPTURE)) { # préfixe en majuscule - } elseif (preg_match(self::CAMEL_PATTERN2, $camel, $vs, PREG_OFFSET_CAPTURE)) { + } elseif (preg_match(self::CAMEL_PATTERN2, $camel, $ms, PREG_OFFSET_CAPTURE)) { # préfixe en minuscule } else { throw ValueException::invalid_kind($camel, "camel string"); } - $parts[] = strtolower($vs[1][0]); - $index = intval($vs[1][1]) + strlen($vs[1][0]); - while (preg_match(self::CAMEL_PATTERN3, $camel, $vs, PREG_OFFSET_CAPTURE, $index)) { - $parts[] = strtolower($vs[1][0]); - $index = intval($vs[1][1]) + strlen($vs[1][0]); + $parts[] = strtolower($ms[1][0]); + $index = intval($ms[1][1]) + strlen($ms[1][0]); + while (preg_match(self::CAMEL_PATTERN3, $camel, $ms, PREG_OFFSET_CAPTURE, $index) !== false && $ms) { + $parts[] = strtolower($ms[1][0]); + $index = intval($ms[1][1]) + strlen($ms[1][0]); } $us = implode($delimiter, $parts); if ($upper) $us = strtoupper($us); - return $us; + return "$prefix$us"; } const US_PATTERN = '/([ _\-\t\r\n\f\v])/'; @@ -372,6 +393,11 @@ class str { */ static final function us2camel(?string $us, ?string $delimiters=null): ?string { if ($us === null || $us === "") return $us; + $prefix = null; + if (preg_match('/^(_+)(.*)/', $us, $ms)) { + $prefix = $ms[1]; + $us = $ms[2]; + } if ($delimiters === null) $pattern = self::US_PATTERN; else $pattern = '/(['.preg_quote($delimiters).'])/'; $parts = preg_split($pattern, $us); @@ -382,6 +408,6 @@ class str { if ($i > 0) $part = ucfirst($part); $parts[$i] = $part; } - return implode("", $parts); + return $prefix.implode("", $parts); } } diff --git a/php/src/text/Word.php b/php/src/text/Word.php index 4c73019..7369b5f 100644 --- a/php/src/text/Word.php +++ b/php/src/text/Word.php @@ -1,8 +1,8 @@ $length) { + if ($ellips && $length > 3) $s = mb_substr($s, 0, $length - 3)."..."; + else $s = mb_substr($s, 0, $length); + } + if ($suffix !== null) $s .= $suffix; + return $s; + } + + /** trimmer $s */ + static final function trim(?string $s): ?string { + if ($s === null) return null; + return trim($s); + } + + /** trimmer $s à gauche */ + static final function ltrim(?string $s): ?string { + if ($s === null) return null; + return ltrim($s); + } + + /** trimmer $s à droite */ + static final function rtrim(?string $s): ?string { + if ($s === null) return null; + return rtrim($s); + } + + static final function left(?string $s, int $size): ?string { + if ($s === null) return null; + return mb_str_pad($s, $size); + } + + static final function right(?string $s, int $size): ?string { + if ($s === null) return null; + return mb_str_pad($s, $size, " ", STR_PAD_LEFT); + } + + static final function center(?string $s, int $size): ?string { + if ($s === null) return null; + return mb_str_pad($s, $size, " ", STR_PAD_BOTH); + } + + static final function pad0(?string $s, int $size): ?string { + if ($s === null) return null; + return mb_str_pad($s, $size, "0", STR_PAD_LEFT); + } + + static final function lower(?string $s): ?string { + if ($s === null) return null; + return mb_strtolower($s); + } + + static final function lower1(?string $s): ?string { + if ($s === null) return null; + return mb_strtolower(mb_substr($s, 0, 1)).mb_substr($s, 1); + } + + static final function upper(?string $s): ?string { + if ($s === null) return null; + return mb_strtoupper($s); + } + + static final function upper1(?string $s): ?string { + if ($s === null) return null; + return mb_strtoupper(mb_substr($s, 0, 1)).mb_substr($s, 1); + } + + static final function upperw(?string $s, ?string $delimiters=null): ?string { + if ($s === null) return null; + if ($delimiters === null) $delimiters = " _-\t\r\n\f\v"; + $pattern = "/([".preg_quote($delimiters)."])/u"; + $words = preg_split($pattern, $s, -1, PREG_SPLIT_DELIM_CAPTURE); + $max = count($words) - 1; + $ucwords = []; + for ($i = 0; $i < $max; $i += 2) { + $s = $words[$i]; + $ucwords[] = mb_strtoupper(mb_substr($s, 0, 1)).mb_substr($s, 1); + $ucwords[] = $words[$i + 1]; + } + $s = $words[$max]; + $ucwords[] = mb_strtoupper(mb_substr($s, 0, 1)).mb_substr($s, 1); + return implode("", $ucwords); + } + + protected static final function _starts_with(string $prefix, string $s, ?int $min_len=null): bool { + if ($prefix === $s) return true; + $len = mb_strlen($prefix); + if ($min_len !== null && ($len < $min_len || $len > mb_strlen($s))) return false; + return $len == 0 || $prefix === mb_substr($s, 0, $len); + } + + /** + * tester si $s commence par $prefix + * par exemple: + * - starts_with("", "whatever") est true + * - starts_with("fi", "first") est true + * - starts_with("no", "yes") est false + * + * si $min_len n'est pas null, c'est la longueur minimum requise de $prefix + * pour qu'on teste la correspondance. dans le cas contraire, la valeur de + * retour est toujours false, sauf s'il y a égalité. e.g + * - starts_with("a", "abc", 2) est false + * - starts_with("a", "a", 2) est true + */ + static final function starts_with(?string $prefix, ?string $s, ?int $min_len=null): bool { + if ($s === null || $prefix === null) return false; + else return self::_starts_with($prefix, $s, $min_len); + } + + /** Retourner $s sans le préfixe $prefix s'il existe */ + static final function without_prefix(?string $prefix, ?string $s): ?string { + if ($s === null || $prefix === null) return $s; + if (self::_starts_with($prefix, $s)) $s = mb_substr($s, mb_strlen($prefix)); + return $s; + } + + /** + * modifier $s en place pour supprimer le préfixe $prefix s'il existe + * + * retourner true si le préfixe a été enlevé. + */ + static final function del_prefix(?string &$s, ?string $prefix): bool { + if ($s === null || !self::_starts_with($prefix, $s)) return false; + $s = self::without_prefix($prefix, $s); + return true; + } + + /** + * Retourner $s avec le préfixe $prefix + * + * Si $unless_exists, ne pas ajouter le préfixe s'il existe déjà + */ + static final function with_prefix(?string $prefix, ?string $s, ?string $sep=null, bool $unless_exists=false): ?string { + if ($s === null || $prefix === null) return $s; + if (!self::_starts_with($prefix, $s) || !$unless_exists) $s = $prefix.$sep.$s; + return $s; + } + + /** + * modifier $s en place pour ajouter le préfixe $prefix + * + * retourner true si le préfixe a été ajouté. + */ + static final function add_prefix(?string &$s, ?string $prefix, bool $unless_exists=true): bool { + if (($s === null || self::_starts_with($prefix, $s)) && $unless_exists) return false; + $s = self::with_prefix($prefix, $s, null, $unless_exists); + return true; + } + + protected static final function _ends_with(string $suffix, string $s, ?int $min_len=null): bool { + if ($suffix === $s) return true; + $len = mb_strlen($suffix); + if ($min_len !== null && ($len < $min_len || $len > mb_strlen($s))) return false; + return $len == 0 || $suffix === mb_substr($s, -$len); + } + + /** + * tester si $string se termine par $suffix + * par exemple: + * - ends_with("", "whatever") est true + * - ends_with("st", "first") est true + * - ends_with("no", "yes") est false + * + * si $min_len n'est pas null, c'est la longueur minimum requise de $prefix + * pour qu'on teste la correspondance. dans le cas contraire, la valeur de + * retour est toujours false, sauf s'il y a égalité. e.g + * - ends_with("c", "abc", 2) est false + * - ends_with("c", "c", 2) est true + */ + static final function ends_with(?string $suffix, ?string $s, ?int $min_len=null): bool { + if ($s === null || $suffix === null) return false; + else return self::_ends_with($suffix, $s, $min_len); + } + + /** Retourner $s sans le suffixe $suffix s'il existe */ + static final function without_suffix(?string $suffix, ?string $s): ?string { + if ($s === null || $suffix === null) return $s; + if (self::_ends_with($suffix, $s)) $s = mb_substr($s, 0, -mb_strlen($suffix)); + return $s; + } + + /** + * modifier $s en place pour supprimer le suffixe $suffix s'il existe + * + * retourner true si le suffixe a été enlevé. + */ + static final function del_suffix(?string &$s, ?string $suffix): bool { + if ($s === null || !self::_ends_with($suffix, $s)) return false; + $s = self::without_suffix($suffix, $s); + return true; + } + + /** + * Retourner $s avec le suffixe $suffix + * + * Si $unless_exists, ne pas ajouter le suffixe s'il existe déjà + */ + static final function with_suffix(?string $suffix, ?string $s, ?string $sep=null, bool $unless_exists=false): ?string { + if ($s === null || $suffix === null) return $s; + if (!self::_ends_with($suffix, $s) || !$unless_exists) $s = $s.$sep.$suffix; + return $s; + } + + /** + * modifier $s en place pour ajouter le suffixe $suffix + * + * retourner true si le suffixe a été ajouté. + */ + static final function add_suffix(?string &$s, ?string $suffix, bool $unless_exists=true): bool { + if (($s === null || self::_ends_with($suffix, $s)) && $unless_exists) return false; + $s = self::with_suffix($suffix, $s, null, $unless_exists); + return true; + } + + /** + * ajouter $sep$prefix$text$suffix à $s si $text est non vide + * + * NB: ne rajouter $sep que si $s est non vide + */ + static final function addsep(?string &$s, ?string $sep, ?string $text, ?string $prefix=null, ?string $suffix=null): void { + if (!$text) return; + if ($s) $s .= $sep; + $s .= $prefix.$text.$suffix; + } + + /** si $text est non vide, ajouter $prefix$text$suffix à $s en séparant la valeur avec un espace */ + static final function add(?string &$s, ?string $text, ?string $prefix=null, ?string $suffix=null): void { + self::addsep($s, " ", $text, $prefix, $suffix); + } + + ############################################################################# + # divers + + /** + * supprimer les diacritiques de la chaine $text + * + * la translitération se fait avec les règles de la locale spécifiée. + * NB: la translitération ne fonctionne pas si LC_CTYPE == C ou POISX + */ + static final function remove_diacritics(?string $text, string $locale="fr_FR.UTF-8"): ?string { + if ($text === null) return null; + #XXX est-ce thread-safe? + $olocale = setlocale(LC_CTYPE, 0); + try { + setlocale(LC_CTYPE, $locale); + $clean = @iconv("UTF-8", "US-ASCII//TRANSLIT", $text); + if ($clean === false) $clean = ""; + return $clean; + } finally { + setlocale(LC_CTYPE, $olocale); + } + } +} diff --git a/php/src/web/params/G.php b/php/src/web/params/G.php index febdfd3..ee37af2 100644 --- a/php/src/web/params/G.php +++ b/php/src/web/params/G.php @@ -25,4 +25,8 @@ class G { static final function set(string $name, ?string $value): void { $_GET[$name] = $value; } + + static final function xselect(?array $includes, ?array $excludes=null, ?array $merge=null): array { + return cl::merge(cl::xselect($_GET, $includes, $excludes), $merge); + } } diff --git a/php/tests/app/LongTaskApp.php b/php/tests/app/LongTaskApp.php new file mode 100644 index 0000000..d98bdec --- /dev/null +++ b/php/tests/app/LongTaskApp.php @@ -0,0 +1,20 @@ + 0) { + msg::print("step $step"); + sleep(1); + } + } +} diff --git a/php/tests/app/launcherTest.php b/php/tests/app/launcherTest.php new file mode 100644 index 0000000..9f07692 --- /dev/null +++ b/php/tests/app/launcherTest.php @@ -0,0 +1,20 @@ + false])); + self::assertSame(["--a"], launcher::verifix_args(["a" => true])); + self::assertSame(["--a", "value"], launcher::verifix_args(["a" => "value"])); + self::assertSame(["--a", "52"], launcher::verifix_args(["a" => 52])); + self::assertSame(["--aa-bb", "value"], launcher::verifix_args(["aaBb" => "value"])); + self::assertSame(["--aa-bb", "value"], launcher::verifix_args(["aa-Bb" => "value"])); + self::assertSame(["--aa-bb", "value"], launcher::verifix_args(["aa_Bb" => "value"])); + self::assertSame(["---aa-bb", "value"], launcher::verifix_args(["_aa_Bb" => "value"])); + } +} diff --git a/php/tests/db/sqlite/.gitignore b/php/tests/db/sqlite/.gitignore index 3d45538..6ab0f32 100644 --- a/php/tests/db/sqlite/.gitignore +++ b/php/tests/db/sqlite/.gitignore @@ -1 +1 @@ -/capacitor.db +/capacitor.db* diff --git a/php/tests/db/sqlite/SqliteStorageTest.php b/php/tests/db/sqlite/SqliteStorageTest.php index 65ac92f..e40ccdf 100644 --- a/php/tests/db/sqlite/SqliteStorageTest.php +++ b/php/tests/db/sqlite/SqliteStorageTest.php @@ -2,6 +2,7 @@ namespace nulib\db\sqlite; use nulib\tests\TestCase; +use nulib\cl; use nulib\db\Capacitor; use nulib\db\CapacitorChannel; @@ -11,7 +12,7 @@ class SqliteStorageTest extends TestCase { $storage->charge($channel, "first"); $storage->charge($channel, "second"); $storage->charge($channel, "third"); - $items = iterator_to_array($storage->discharge($channel, false)); + $items = cl::all($storage->discharge($channel, false)); self::assertSame(["first", "second", "third"], $items); } @@ -32,10 +33,9 @@ class SqliteStorageTest extends TestCase { $storage = new SqliteStorage(__DIR__.'/capacitor.db'); $storage->addChannel(new class extends CapacitorChannel { const NAME = "arrays"; - function getKeyDefinitions(): ?array { - return ["id" => "integer"]; - } - function getKeyValues($item): ?array { + const COLUMN_DEFINITIONS = ["id" => "integer"]; + + function getItemValues($item): ?array { return ["id" => $item["id"] ?? null]; } }); @@ -49,14 +49,12 @@ class SqliteStorageTest extends TestCase { $storage = new SqliteStorage(__DIR__.'/capacitor.db'); $capacitor = new Capacitor($storage, new class extends CapacitorChannel { const NAME = "each"; + const COLUMN_DEFINITIONS = [ + "age" => "integer", + "done" => "integer default 0", + ]; - function getKeyDefinitions(): ?array { - return [ - "age" => "integer", - "done" => "integer default 0", - ]; - } - function getKeyValues($item): ?array { + function getItemValues($item): ?array { return [ "age" => $item["age"], ]; @@ -79,8 +77,8 @@ class SqliteStorageTest extends TestCase { }; $capacitor->each(["age" => [">", 10]], $setDone, ["++"]); $capacitor->each(["done" => 0], $setDone, null); - Txx(iterator_to_array($capacitor->discharge(null, false))); + Txx(cl::all($capacitor->discharge(false))); $capacitor->close(); self::assertTrue(true); } @@ -89,14 +87,12 @@ class SqliteStorageTest extends TestCase { $storage = new SqliteStorage(__DIR__.'/capacitor.db'); $capacitor = new Capacitor($storage, new class extends CapacitorChannel { const NAME = "pk"; + const COLUMN_DEFINITIONS = [ + "id_" => "varchar primary key", + "done" => "integer default 0", + ]; - function getKeyDefinitions(): ?array { - return [ - "id_" => "varchar primary key", - "done" => "integer default 0", - ]; - } - function getKeyValues($item): ?array { + function getItemValues($item): ?array { return [ "id_" => $item["numero"], ]; @@ -114,4 +110,235 @@ class SqliteStorageTest extends TestCase { $capacitor->close(); self::assertTrue(true); } + + function testSum() { + $storage = new SqliteStorage(__DIR__.'/capacitor.db'); + $capacitor = new Capacitor($storage, new class extends CapacitorChannel { + const NAME = "sum"; + const COLUMN_DEFINITIONS = [ + "a__" => "varchar", + "b__" => "varchar", + "b__sum_" => self::SUM_DEFINITION, + ]; + + function getItemValues($item): ?array { + return [ + "a" => $item["a"], + "b" => $item["b"], + ]; + } + }); + + $capacitor->reset(); + $capacitor->charge(["a" => null, "b" => null]); + $capacitor->charge(["a" => "first", "b" => "second"]); + + Txx("=== all"); + /** @var Sqlite $sqlite */ + $sqlite = $capacitor->getStorage()->db(); + Txx(cl::all($sqlite->all([ + "select", + "from" => $capacitor->getChannel()->getTableName(), + ]))); + Txx("=== each"); + $capacitor->each(null, function ($item, $values) { + Txx($values); + }); + + $capacitor->close(); + self::assertTrue(true); + } + + function testEachValues() { + # tester que values contient bien toutes les valeurs de la ligne + $storage = new SqliteStorage(__DIR__.'/capacitor.db'); + $capacitor = new Capacitor($storage, new class extends CapacitorChannel { + const NAME = "each_values"; + const COLUMN_DEFINITIONS = [ + "name" => "varchar primary key", + "age" => "integer", + "done" => "integer default 0", + "notes" => "text", + ]; + + function getItemValues($item): ?array { + return [ + "name" => $item["name"], + "age" => $item["age"], + ]; + } + }); + + $capacitor->reset(); + $capacitor->charge(["name" => "first", "age" => 5], function($item, ?array $values, ?array $pvalues) { + self::assertSame("first", $item["name"]); + self::assertSame(5, $item["age"]); + self::assertnotnull($values); + self::assertSame(["name", "age", "item", "item__sum_", "created_", "modified_"], array_keys($values)); + self::assertSame([ + "name" => "first", + "age" => 5, + "item" => $item, + ], cl::select($values, ["name", "age", "item"])); + self::assertNull($pvalues); + }); + $capacitor->charge(["name" => "first", "age" => 10], function($item, ?array $values, ?array $pvalues) { + self::assertSame("first", $item["name"]); + self::assertSame(10, $item["age"]); + self::assertnotnull($values); + self::assertSame(["name", "age", "done", "notes", "item", "item__sum_", "created_", "modified_"], array_keys($values)); + self::assertSame([ + "name" => "first", + "age" => 10, + "done" => 0, + "notes" => null, + "item" => $item, + ], cl::select($values, ["name", "age", "done", "notes", "item"])); + self::assertNotNull($pvalues); + self::assertSame([ + "name" => "first", + "age" => 5, + "done" => 0, + "notes" => null, + "item" => ["name" => "first", "age" => 5], + ], cl::select($pvalues, ["name", "age", "done", "notes", "item"])); + }); + + $capacitor->each(null, function($item, ?array $values) { + self::assertSame("first", $item["name"]); + self::assertSame(10, $item["age"]); + self::assertnotnull($values); + self::assertSame(["name", "age", "done", "notes", "item", "item__sum_", "created_", "modified_"], array_keys($values)); + self::assertSame([ + "name" => "first", + "age" => 10, + "done" => 0, + "notes" => null, + "item" => $item, + ], cl::select($values, ["name", "age", "done", "notes", "item"])); + return [ + "done" => 1, + "notes" => "modified", + ]; + }); + $capacitor->charge(["name" => "first", "age" => 10], function($item, ?array $values, ?array $pvalues) { + self::assertSame("first", $item["name"]); + self::assertSame(10, $item["age"]); + self::assertnotnull($values); + self::assertSame(["name", "age", "done", "notes", "item", "item__sum_", "created_", "modified_"], array_keys($values)); + self::assertSame([ + "name" => "first", + "age" => 10, + "done" => 1, + "notes" => "modified", + "item" => $item, + ], cl::select($values, ["name", "age", "done", "notes", "item"])); + self::assertNotNull($pvalues); + self::assertSame([ + "name" => "first", + "age" => 10, + "done" => 1, + "notes" => "modified", + "item" => $item, + ], cl::select($pvalues, ["name", "age", "done", "notes", "item"])); + }); + + $capacitor->charge(["name" => "first", "age" => 20], function($item, ?array $values, ?array $pvalues) { + self::assertSame("first", $item["name"]); + self::assertSame(20, $item["age"]); + self::assertnotnull($values); + self::assertSame(["name", "age", "done", "notes", "item", "item__sum_", "created_", "modified_"], array_keys($values)); + self::assertSame([ + "name" => "first", + "age" => 20, + "done" => 1, + "notes" => "modified", + "item" => $item, + ], cl::select($values, ["name", "age", "done", "notes", "item"])); + self::assertNotNull($pvalues); + self::assertSame([ + "name" => "first", + "age" => 10, + "done" => 1, + "notes" => "modified", + "item" => ["name" => "first", "age" => 10], + ], cl::select($pvalues, ["name", "age", "done", "notes", "item"])); + }); + } + + function testSetItemNull() { + # tester le forçage de $îtem à null pour économiser la place + $storage = new SqliteStorage(__DIR__.'/capacitor.db'); + $capacitor = new Capacitor($storage, new class extends CapacitorChannel { + const NAME = "set_item_null"; + const COLUMN_DEFINITIONS = [ + "name" => "varchar primary key", + "age" => "integer", + "done" => "integer default 0", + "notes" => "text", + ]; + + function getItemValues($item): ?array { + return [ + "name" => $item["name"], + "age" => $item["age"], + ]; + } + }); + + $capacitor->reset(); + $nbModified = $capacitor->charge(["name" => "first", "age" => 5], function ($item, ?array $values, ?array $pvalues) { + self::assertSame([ + "name" => "first", "age" => 5, + "item" => $item, + ], cl::select($values, ["name", "age", "item"])); + return ["item" => null]; + }); + self::assertSame(1, $nbModified); + sleep(1); + # nb: on met des sleep() pour que la date de modification soit systématiquement différente + + $nbModified = $capacitor->charge(["name" => "first", "age" => 10], function ($item, ?array $values, ?array $pvalues) { + self::assertSame([ + "name" => "first", "age" => 10, + "item" => $item, "item__sum_" => "9181336dfca20c86313d6065d89aa2ad5070b0fc", + ], cl::select($values, ["name", "age", "item", "item__sum_"])); + self::assertSame([ + "name" => "first", "age" => 5, + "item" => null, "item__sum_" => null, + ], cl::select($pvalues, ["name", "age", "item", "item__sum_"])); + return ["item" => null]; + }); + self::assertSame(1, $nbModified); + sleep(1); + + # pas de modification ici + $nbModified = $capacitor->charge(["name" => "first", "age" => 10], function ($item, ?array $values, ?array $pvalues) { + self::assertSame([ + "name" => "first", "age" => 10, + "item" => $item, "item__sum_" => "9181336dfca20c86313d6065d89aa2ad5070b0fc", + ], cl::select($values, ["name", "age", "item", "item__sum_"])); + self::assertSame([ + "name" => "first", "age" => 10, + "item" => null, "item__sum_" => null, + ], cl::select($pvalues, ["name", "age", "item", "item__sum_"])); + return ["item" => null]; + }); + self::assertSame(0, $nbModified); + sleep(1); + + $nbModified = $capacitor->charge(["name" => "first", "age" => 20], function ($item, ?array $values, ?array $pvalues) { + self::assertSame([ + "name" => "first", "age" => 20, + "item" => $item, "item__sum_" => "001b91982b4e0883b75428c0eb28573a5dc5f7a5", + ], cl::select($values, ["name", "age", "item", "item__sum_"])); + self::assertSame([ + "name" => "first", "age" => 10, + "item" => null, "item__sum_" => null, + ], cl::select($pvalues, ["name", "age", "item", "item__sum_"])); + return ["item" => null]; + }); + self::assertSame(1, $nbModified); + sleep(1); + } } diff --git a/php/tests/db/sqlite/_queryTest.php b/php/tests/db/sqlite/_queryTest.php index 92c232d..6d92412 100644 --- a/php/tests/db/sqlite/_queryTest.php +++ b/php/tests/db/sqlite/_queryTest.php @@ -6,86 +6,120 @@ use PHPUnit\Framework\TestCase; class _queryTest extends TestCase { function testParseConds(): void { $sql = $params = null; - _query::parse_conds(null, $sql, $params); + _query_base::parse_conds(null, $sql, $params); self::assertNull($sql); self::assertNull($params); $sql = $params = null; - _query::parse_conds([], $sql, $params); + _query_base::parse_conds([], $sql, $params); self::assertNull($sql); self::assertNull($params); $sql = $params = null; - _query::parse_conds(["col" => null], $sql, $params); + _query_base::parse_conds(["col" => null], $sql, $params); self::assertSame(["col is null"], $sql); self::assertNull($params); $sql = $params = null; - _query::parse_conds(["col = 'value'"], $sql, $params); + _query_base::parse_conds(["col = 'value'"], $sql, $params); self::assertSame(["col = 'value'"], $sql); self::assertNull($params); $sql = $params = null; - _query::parse_conds([["col = 'value'"]], $sql, $params); + _query_base::parse_conds([["col = 'value'"]], $sql, $params); self::assertSame(["col = 'value'"], $sql); self::assertNull($params); $sql = $params = null; - _query::parse_conds(["int" => 42, "string" => "value"], $sql, $params); + _query_base::parse_conds(["int" => 42, "string" => "value"], $sql, $params); self::assertSame(["(int = :int and string = :string)"], $sql); self::assertSame(["int" => 42, "string" => "value"], $params); $sql = $params = null; - _query::parse_conds(["or", "int" => 42, "string" => "value"], $sql, $params); + _query_base::parse_conds(["or", "int" => 42, "string" => "value"], $sql, $params); self::assertSame(["(int = :int or string = :string)"], $sql); self::assertSame(["int" => 42, "string" => "value"], $params); $sql = $params = null; - _query::parse_conds([["int" => 42, "string" => "value"], ["int" => 24, "string" => "eulav"]], $sql, $params); - self::assertSame(["((int = :int and string = :string) and (int = :int1 and string = :string1))"], $sql); - self::assertSame(["int" => 42, "string" => "value", "int1" => 24, "string1" => "eulav"], $params); + _query_base::parse_conds([["int" => 42, "string" => "value"], ["int" => 24, "string" => "eulav"]], $sql, $params); + self::assertSame(["((int = :int and string = :string) and (int = :int2 and string = :string2))"], $sql); + self::assertSame(["int" => 42, "string" => "value", "int2" => 24, "string2" => "eulav"], $params); $sql = $params = null; - _query::parse_conds(["int" => ["is null"], "string" => ["<>", "value"]], $sql, $params); + _query_base::parse_conds(["int" => ["is null"], "string" => ["<>", "value"]], $sql, $params); self::assertSame(["(int is null and string <> :string)"], $sql); self::assertSame(["string" => "value"], $params); + + $sql = $params = null; + _query_base::parse_conds(["col" => ["between", "lower", "upper"]], $sql, $params); + self::assertSame(["col between :col and :col2"], $sql); + self::assertSame(["col" => "lower", "col2" => "upper"], $params); + + $sql = $params = null; + _query_base::parse_conds(["col" => ["in", "one"]], $sql, $params); + self::assertSame(["col in (:col)"], $sql); + self::assertSame(["col" => "one"], $params); + + $sql = $params = null; + _query_base::parse_conds(["col" => ["in", ["one", "two"]]], $sql, $params); + self::assertSame(["col in (:col, :col2)"], $sql); + self::assertSame(["col" => "one", "col2" => "two"], $params); + + $sql = $params = null; + _query_base::parse_conds(["col" => ["=", ["one", "two"]]], $sql, $params); + self::assertSame(["col = :col and col = :col2"], $sql); + self::assertSame(["col" => "one", "col2" => "two"], $params); + + $sql = $params = null; + _query_base::parse_conds(["or", "col" => ["=", ["one", "two"]]], $sql, $params); + self::assertSame(["col = :col or col = :col2"], $sql); + self::assertSame(["col" => "one", "col2" => "two"], $params); + + $sql = $params = null; + _query_base::parse_conds(["col" => ["<>", ["one", "two"]]], $sql, $params); + self::assertSame(["col <> :col and col <> :col2"], $sql); + self::assertSame(["col" => "one", "col2" => "two"], $params); + + $sql = $params = null; + _query_base::parse_conds(["or", "col" => ["<>", ["one", "two"]]], $sql, $params); + self::assertSame(["col <> :col or col <> :col2"], $sql); + self::assertSame(["col" => "one", "col2" => "two"], $params); } function testParseValues(): void { $sql = $params = null; - _query::parse_set_values(null, $sql, $params); + _query_base::parse_set_values(null, $sql, $params); self::assertNull($sql); self::assertNull($params); $sql = $params = null; - _query::parse_set_values([], $sql, $params); + _query_base::parse_set_values([], $sql, $params); self::assertNull($sql); self::assertNull($params); $sql = $params = null; - _query::parse_set_values(["col = 'value'"], $sql, $params); + _query_base::parse_set_values(["col = 'value'"], $sql, $params); self::assertSame(["col = 'value'"], $sql); self::assertNull($params); $sql = $params = null; - _query::parse_set_values([["col = 'value'"]], $sql, $params); + _query_base::parse_set_values([["col = 'value'"]], $sql, $params); self::assertSame(["col = 'value'"], $sql); self::assertNull($params); $sql = $params = null; - _query::parse_set_values(["int" => 42, "string" => "value"], $sql, $params); + _query_base::parse_set_values(["int" => 42, "string" => "value"], $sql, $params); self::assertSame(["int = :int", "string = :string"], $sql); self::assertSame(["int" => 42, "string" => "value"], $params); $sql = $params = null; - _query::parse_set_values(["int" => 42, "string" => "value"], $sql, $params); + _query_base::parse_set_values(["int" => 42, "string" => "value"], $sql, $params); self::assertSame(["int = :int", "string = :string"], $sql); self::assertSame(["int" => 42, "string" => "value"], $params); $sql = $params = null; - _query::parse_set_values([["int" => 42, "string" => "value"], ["int" => 24, "string" => "eulav"]], $sql, $params); - self::assertSame(["int = :int", "string = :string", "int = :int1", "string = :string1"], $sql); - self::assertSame(["int" => 42, "string" => "value", "int1" => 24, "string1" => "eulav"], $params); + _query_base::parse_set_values([["int" => 42, "string" => "value"], ["int" => 24, "string" => "eulav"]], $sql, $params); + self::assertSame(["int = :int", "string = :string", "int = :int2", "string = :string2"], $sql); + self::assertSame(["int" => 42, "string" => "value", "int2" => 24, "string2" => "eulav"], $params); } - } diff --git a/php/tests/php/content/cTest.php b/php/tests/php/content/cTest.php new file mode 100644 index 0000000..2f31b4a --- /dev/null +++ b/php/tests/php/content/cTest.php @@ -0,0 +1,40 @@ +")); + self::assertSame("pouet", c::to_string(["pouet"])); + self::assertSame("hello world", c::to_string(["hello", "world"])); + self::assertSame("hello1 world", c::to_string(["hello1", "world"])); + self::assertSame("hello", c::to_string(["hello", ""])); + self::assertSame("world", c::to_string(["", "world"])); + self::assertSame("hello,world", c::to_string(["hello,", "world"])); + self::assertSame("hello world", c::to_string(["hello ", "world"])); + self::assertSame("hello. world", c::to_string(["hello.", "world"])); + self::assertSame("hello.", c::to_string(["hello.", ""])); + + self::assertSame( + "

title<q/>

hellobrave<q/>world

", + c::to_string([ + [html::H1, "title"], + [html::P, [ + "hello", + [html::SPAN, "brave"], + [html::SPAN, ["world"]], + ]], + ])); + } + + function testXxx() { + $content = [[v::h1, "hello"]]; + self::assertSame("

hello

", c::to_string($content)); + } +} + diff --git a/php/tests/php/content/contentTest.php b/php/tests/php/content/contentTest.php deleted file mode 100644 index 3bf5ad2..0000000 --- a/php/tests/php/content/contentTest.php +++ /dev/null @@ -1,39 +0,0 @@ -", "")); - self::assertSame([""], content::resolve_static([""], [""])); - - self::assertSame( - "

title<q/>

hellobrave<q/>world

", - content::to_string([ - [html::H1, "title"], - [html::P, [ - "hello", - [html::SPAN, "brave"], - [html::SPAN, ["world"]], - ]], - ])); - } -} - diff --git a/php/tests/php/content/impl/AStaticContent.php b/php/tests/php/content/impl/AStaticContent.php deleted file mode 100644 index cf81cd9..0000000 --- a/php/tests/php/content/impl/AStaticContent.php +++ /dev/null @@ -1,13 +0,0 @@ -tag = $tag; - $this->contents = $contents; + $this->content = $content; } protected $tag; - protected $contents; + protected $content; function getContent(): iterable { return [ "<$this->tag>", - ...content::quote($this->contents), + ...c::q($this->content), "tag>", ]; } diff --git a/php/tests/php/content/impl/html.php b/php/tests/php/content/impl/html.php index fdb0b28..3567b2e 100644 --- a/php/tests/php/content/impl/html.php +++ b/php/tests/php/content/impl/html.php @@ -1,16 +1,14 @@ ")); - self::assertTrue(func::is_method("->xxx")); self::assertFalse(func::is_method([])); self::assertFalse(func::is_method([""])); - self::assertTrue(func::is_method(["->xxx"])); - self::assertTrue(func::is_method(["->xxx", "aaa"])); self::assertFalse(func::is_method([null, "->"])); - self::assertTrue(func::is_method([null, "->yyy"])); self::assertFalse(func::is_method(["xxx", "->"])); + + self::assertTrue(func::is_method("->xxx")); + self::assertTrue(func::is_method(["->xxx"])); + self::assertTrue(func::is_method([null, "->yyy"])); self::assertTrue(func::is_method(["xxx", "->yyy"])); self::assertTrue(func::is_method([null, "->yyy", "aaa"])); self::assertTrue(func::is_method(["xxx", "->yyy", "aaa"])); @@ -95,39 +76,15 @@ namespace nulib\php { function testFix_method() { $object = new \stdClass(); - $func= null; - func::fix_method($func, $object); - self::assertSame(null, $func); - $func= ""; - func::fix_method($func, $object); - self::assertSame("", $func); - $func= "->"; - func::fix_method($func, $object); - self::assertSame("->", $func); $func= "->xxx"; func::fix_method($func, $object); self::assertSame([$object, "xxx"], $func); - $func= []; - func::fix_method($func, $object); - self::assertSame([], $func); - $func= [""]; - func::fix_method($func, $object); - self::assertSame([""], $func); $func= ["->xxx"]; func::fix_method($func, $object); self::assertSame([$object, "xxx"], $func); - $func= ["->xxx", "aaa"]; - func::fix_method($func, $object); - self::assertSame([$object, "xxx", "aaa"], $func); - $func= [null, "->"]; - func::fix_method($func, $object); - self::assertSame([null, "->"], $func); $func= [null, "->yyy"]; func::fix_method($func, $object); self::assertSame([$object, "yyy"], $func); - $func= ["xxx", "->"]; - func::fix_method($func, $object); - self::assertSame(["xxx", "->"], $func); $func= ["xxx", "->yyy"]; func::fix_method($func, $object); self::assertSame([$object, "yyy"], $func); diff --git a/php/tests/php/time/DateTest.php b/php/tests/php/time/DateTest.php index a045154..857ca33 100644 --- a/php/tests/php/time/DateTest.php +++ b/php/tests/php/time/DateTest.php @@ -52,4 +52,34 @@ class DateTest extends TestCase { self::assertSame("05/04/2024", strval(new Date("5/4/2024 09:15"))); self::assertSame("05/04/2024", strval(new Date("5/4/2024 09h15"))); } + + function testCompare() { + $a = new Date("10/02/2024"); + $b = new Date("15/02/2024"); + $c = new Date("20/02/2024"); + $a2 = new Date("10/02/2024"); + $b2 = new Date("15/02/2024"); + $c2 = new Date("20/02/2024"); + + self::assertTrue($a == $a2); + self::assertFalse($a === $a2); + self::assertTrue($b == $b2); + self::assertTrue($c == $c2); + + self::assertFalse($a < $a); + self::assertTrue($a < $b); + self::assertTrue($a < $c); + + self::assertTrue($a <= $a); + self::assertTrue($a <= $b); + self::assertTrue($a <= $c); + + self::assertFalse($c > $c); + self::assertTrue($c > $b); + self::assertTrue($c > $a); + + self::assertTrue($c >= $c); + self::assertTrue($c >= $b); + self::assertTrue($c >= $a); + } } diff --git a/php/tests/php/time/DateTimeTest.php b/php/tests/php/time/DateTimeTest.php index 83d111b..088bc79 100644 --- a/php/tests/php/time/DateTimeTest.php +++ b/php/tests/php/time/DateTimeTest.php @@ -61,4 +61,49 @@ class DateTimeTest extends TestCase { self::assertSame("05/04/2024 09:15:00", strval(new DateTime("5/4/2024 09:15"))); self::assertSame("05/04/2024 09:15:00", strval(new DateTime("5/4/2024 09h15"))); } + + function testCompare() { + $a = new DateTime("10/02/2024"); + $a2 = new DateTime("10/02/2024 8:30"); + $a3 = new DateTime("10/02/2024 15:45"); + $b = new DateTime("15/02/2024"); + $b2 = new DateTime("15/02/2024 8:30"); + $b3 = new DateTime("15/02/2024 15:45"); + $x = new DateTime("10/02/2024"); + $x2 = new DateTime("10/02/2024 8:30"); + $x3 = new DateTime("10/02/2024 15:45"); + + self::assertTrue($a == $x); + self::assertFalse($a === $x); + self::assertTrue($a2 == $x2); + self::assertTrue($a3 == $x3); + + self::assertFalse($a < $a); + self::assertTrue($a < $a2); + self::assertTrue($a < $a3); + self::assertTrue($a < $b); + self::assertTrue($a < $b2); + self::assertTrue($a < $b3); + + self::assertTrue($a <= $a); + self::assertTrue($a <= $a2); + self::assertTrue($a <= $a3); + self::assertTrue($a <= $b); + self::assertTrue($a <= $b2); + self::assertTrue($a <= $b3); + + self::assertTrue($b > $a); + self::assertTrue($b > $a2); + self::assertTrue($b > $a3); + self::assertFalse($b > $b); + self::assertFalse($b > $b2); + self::assertFalse($b > $b3); + + self::assertTrue($b >= $a); + self::assertTrue($b >= $a2); + self::assertTrue($b >= $a3); + self::assertTrue($b >= $b); + self::assertFalse($b >= $b2); + self::assertFalse($b >= $b3); + } } diff --git a/php/tests/strTest.php b/php/tests/strTest.php new file mode 100644 index 0000000..92785fc --- /dev/null +++ b/php/tests/strTest.php @@ -0,0 +1,28 @@ + [ 'name' => '', 'type' => '', @@ -12,6 +13,7 @@ class uploadsTest extends TestCase { 'error' => 4, 'size' => 0, ], + # name=multiple[], name=multiple[] 'multiple' => [ 'name' => [ 0 => '', @@ -34,6 +36,7 @@ class uploadsTest extends TestCase { 1 => 0, ], ], + # name=onelevel[a], name=onelevel[b] 'onelevel' => [ 'name' => [ 'a' => '', @@ -56,6 +59,7 @@ class uploadsTest extends TestCase { 'b' => 0, ], ], + # name=multiplelevel[a][], name=multiplelevel[a][], name=multiplelevel[b][], name=multiplelevel[b][] 'multiplelevel' => [ 'name' => [ 'a' => [ @@ -111,6 +115,7 @@ class uploadsTest extends TestCase { ]; const PARSED = [ + # name="simple" 'simple' => [ 'name' => '', 'type' => '', @@ -118,6 +123,7 @@ class uploadsTest extends TestCase { 'error' => 4, 'size' => 0, ], + # name=multiple[], name=multiple[] 'multiple' => [ 0 => [ 'name' => '', @@ -134,6 +140,7 @@ class uploadsTest extends TestCase { 'size' => 0, ], ], + # name=onelevel[a], name=onelevel[b] 'onelevel' => [ 'a' => [ 'name' => '', @@ -150,6 +157,7 @@ class uploadsTest extends TestCase { 'size' => 0, ], ], + # name=multiplelevel[a][], name=multiplelevel[a][], name=multiplelevel[b][], name=multiplelevel[b][] 'multiplelevel' => [ 'a' => [ 0 => [