From 584e831f8c5098fadec76fda56fe48a4756f7727 Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 6 Jul 2025 22:16:25 +0200 Subject: [PATCH] =?UTF-8?q?Sprint=201.2=20abgeschlossen:=20Model.php=20und?= =?UTF-8?q?=20Collection.php=20mit=20vollst=C3=A4ndigen=20ORM-=20und=20Col?= =?UTF-8?q?lection-Features=20implementiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PHASE_3_TRACKER.md | 23 +- classes/Collection.php | 225 +++++++++++++++ classes/Model.php | 620 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 856 insertions(+), 12 deletions(-) create mode 100644 classes/Collection.php create mode 100644 classes/Model.php diff --git a/PHASE_3_TRACKER.md b/PHASE_3_TRACKER.md index 600a884..61cb888 100644 --- a/PHASE_3_TRACKER.md +++ b/PHASE_3_TRACKER.md @@ -4,10 +4,10 @@ **Ziel:** 100% PrestaShop-Kompatibilität mit allen Core- und erweiterten Funktionen **Timeline:** 6 Monate (24 Wochen) - 6 Milestones mit je 4 Sprints **Status:** In Bearbeitung -**Aktueller Fortschritt:** 8.33% (2 von 24 Sprints abgeschlossen) +**Aktueller Fortschritt:** 12.5% (3 von 24 Sprints abgeschlossen) ## MILESTONE 1: CORE-SYSTEM ERWEITERUNG (Woche 1-4) -**Status:** In Bearbeitung (50% abgeschlossen) +**Status:** In Bearbeitung (75% abgeschlossen) ### Sprint 1.1: Tools.php & Context.php Erweiterung ✅ ABGESCHLOSSEN - ✅ Security-Funktionen (hash, getToken, AdminToken, String-Operationen) @@ -17,11 +17,11 @@ - ✅ Context.php Erweiterung (getContext, cloneContext, Device-Erkennung) - ✅ Cart.php Erweiterung (nbProducts, addCartRule, getOrderTotal, etc.) -### Sprint 1.2: Datenbank & ORM 🔄 IN BEARBEITUNG (75% abgeschlossen) +### Sprint 1.2: Datenbank & ORM ✅ ABGESCHLOSSEN - ✅ Db.php Erweiterung (query, insert, update, delete, execute, etc.) - ✅ ObjectModel.php Erweiterung (save, add, update, delete, duplicateObject, validateFields, getFields, formatValue, hydrate, getDefinition, etc.) -- 🔄 Model.php Erweiterung (CRUD-Operationen, Validierung, Beziehungen) -- ⏳ Collection.php Erweiterung (Filter, Sortierung, Pagination) +- ✅ Model.php Erweiterung (CRUD-Operationen, Validierung, Beziehungen) +- ✅ Collection.php Erweiterung (Filter, Sortierung, Pagination) ### Sprint 1.3: Core-Klassen System - ⏳ **Product.php** (8000+ Zeilen) - Vollständige Produktverwaltung @@ -299,8 +299,8 @@ - `getTranslations()`, `updateTranslations()` - Übersetzungsverwaltung ## GESAMTFORTSCHRITT -**Aktueller Stand:** 8.33% (2 von 24 Sprints abgeschlossen) -**Nächster Meilenstein:** Sprint 1.2 abschließen (Model.php & Collection.php) +**Aktueller Stand:** 12.5% (3 von 24 Sprints abgeschlossen) +**Nächster Meilenstein:** Sprint 1.3 starten (Core-Klassen System) ## TECHNISCHE DETAILS - **Datenbank:** MySQL 8.0+ mit erweiterten Tabellen @@ -317,10 +317,9 @@ - ✅ Dokumentation bei jedem Sprint ## NÄCHSTE SCHRITTE -1. **Sprint 1.2 abschließen:** Model.php und Collection.php implementieren -2. **Sprint 1.3 starten:** Core-Klassen System (Product.php, Category.php, Customer.php, Order.php) -3. **Sprint 1.4 starten:** Erweiterte Core-Klassen (Address.php, Manufacturer.php, Supplier.php, Currency.php, Language.php) -4. **Milestone 1 abschließen:** Core-System vollständig +1. **Sprint 1.3 starten:** Core-Klassen System (Product.php, Category.php, Customer.php, Order.php) +2. **Sprint 1.4 starten:** Erweiterte Core-Klassen (Address.php, Manufacturer.php, Supplier.php, Currency.php, Language.php) +3. **Milestone 1 abschließen:** Core-System vollständig --- -*Letzte Aktualisierung: Sprint 1.2 - ObjectModel.php Erweiterung abgeschlossen* \ No newline at end of file +*Letzte Aktualisierung: Sprint 1.2 (Model.php & Collection.php) abgeschlossen* \ No newline at end of file diff --git a/classes/Collection.php b/classes/Collection.php new file mode 100644 index 0000000..9d7f901 --- /dev/null +++ b/classes/Collection.php @@ -0,0 +1,225 @@ +items = $items; + } + + /** + * WHERE-Bedingung (Vergleich) + */ + public function where($key, $operator, $value = null) + { + if (func_num_args() == 2) { + $value = $operator; + $operator = '='; + } + $filtered = array_filter($this->items, function ($item) use ($key, $operator, $value) { + $itemValue = is_array($item) ? $item[$key] : $item->$key; + switch ($operator) { + case '=': return $itemValue == $value; + case '!=': return $itemValue != $value; + case '>': return $itemValue > $value; + case '<': return $itemValue < $value; + case '>=': return $itemValue >= $value; + case '<=': return $itemValue <= $value; + case 'like': return stripos($itemValue, $value) !== false; + default: return false; + } + }); + return new static(array_values($filtered)); + } + + /** + * WHERE IN + */ + public function whereIn($key, array $values) + { + $filtered = array_filter($this->items, function ($item) use ($key, $values) { + $itemValue = is_array($item) ? $item[$key] : $item->$key; + return in_array($itemValue, $values); + }); + return new static(array_values($filtered)); + } + + /** + * WHERE BETWEEN + */ + public function whereBetween($key, array $range) + { + $filtered = array_filter($this->items, function ($item) use ($key, $range) { + $itemValue = is_array($item) ? $item[$key] : $item->$key; + return $itemValue >= $range[0] && $itemValue <= $range[1]; + }); + return new static(array_values($filtered)); + } + + /** + * WHERE NULL + */ + public function whereNull($key) + { + $filtered = array_filter($this->items, function ($item) use ($key) { + $itemValue = is_array($item) ? $item[$key] : $item->$key; + return is_null($itemValue); + }); + return new static(array_values($filtered)); + } + + /** + * WHERE NOT NULL + */ + public function whereNotNull($key) + { + $filtered = array_filter($this->items, function ($item) use ($key) { + $itemValue = is_array($item) ? $item[$key] : $item->$key; + return !is_null($itemValue); + }); + return new static(array_values($filtered)); + } + + /** + * ORDER BY + */ + public function orderBy($key) + { + $items = $this->items; + usort($items, function ($a, $b) use ($key) { + $aValue = is_array($a) ? $a[$key] : $a->$key; + $bValue = is_array($b) ? $b[$key] : $b->$key; + return $aValue <=> $bValue; + }); + return new static($items); + } + + /** + * ORDER BY DESC + */ + public function orderByDesc($key) + { + $items = $this->items; + usort($items, function ($a, $b) use ($key) { + $aValue = is_array($a) ? $a[$key] : $a->$key; + $bValue = is_array($b) ? $b[$key] : $b->$key; + return $bValue <=> $aValue; + }); + return new static($items); + } + + /** + * Neueste zuerst (nach Feld, default: date_add) + */ + public function latest($key = 'date_add') + { + return $this->orderByDesc($key); + } + + /** + * Älteste zuerst (nach Feld, default: date_add) + */ + public function oldest($key = 'date_add') + { + return $this->orderBy($key); + } + + /** + * Pagination + */ + public function paginate($perPage = 20, $page = 1) + { + $offset = ($page - 1) * $perPage; + $items = array_slice($this->items, $offset, $perPage); + return new static($items); + } + + /** + * Einfache Pagination (liefert Array) + */ + public function simplePaginate($perPage = 20, $page = 1) + { + $offset = ($page - 1) * $perPage; + return array_slice($this->items, $offset, $perPage); + } + + /** + * Chunk-Verarbeitung + */ + public function chunk($size, callable $callback) + { + $chunks = array_chunk($this->items, $size); + foreach ($chunks as $chunk) { + $callback(new static($chunk)); + } + } + + /** + * Iterator (each) + */ + public function each(callable $callback) + { + foreach ($this->items as $key => $item) { + $callback($item, $key); + } + return $this; + } + + /** + * Zähle Elemente + */ + public function count() + { + return count($this->items); + } + + /** + * IteratorAggregate + */ + public function getIterator() + { + return new \ArrayIterator($this->items); + } + + /** + * Alle Elemente als Array + */ + public function all() + { + return $this->items; + } + + /** + * Erstes Element + */ + public function first() + { + return reset($this->items); + } + + /** + * Letztes Element + */ + public function last() + { + return end($this->items); + } + + /** + * Leere Collection? + */ + public function isEmpty() + { + return empty($this->items); + } +} \ No newline at end of file diff --git a/classes/Model.php b/classes/Model.php new file mode 100644 index 0000000..afe33f1 --- /dev/null +++ b/classes/Model.php @@ -0,0 +1,620 @@ +initializeModel(); + } + + /** + * Initialize model + */ + protected function initializeModel() + { + // Set default values + if ($this->timestamps) { + if (property_exists($this, $this->createdAtField)) { + $this->{$this->createdAtField} = date('Y-m-d H:i:s'); + } + if (property_exists($this, $this->updatedAtField)) { + $this->{$this->updatedAtField} = date('Y-m-d H:i:s'); + } + } + } + + /** + * Create new model instance + * + * @param array $attributes + * @return static + */ + public static function create(array $attributes = []) + { + $model = new static(); + $model->fill($attributes); + $model->save(); + return $model; + } + + /** + * Find model by ID + * + * @param int $id + * @return static|null + */ + public static function find($id) + { + return new static($id); + } + + /** + * Find model by ID or throw exception + * + * @param int $id + * @return static + * @throws Exception + */ + public static function findOrFail($id) + { + $model = static::find($id); + if (!$model || !$model->id) { + throw new Exception('Model not found with ID: ' . $id); + } + return $model; + } + + /** + * Find model by field + * + * @param string $field + * @param mixed $value + * @return static|null + */ + public static function findBy($field, $value) + { + $sql = 'SELECT * FROM `' . _DB_PREFIX_ . static::$definition['table'] . '` WHERE `' . pSQL($field) . '` = "' . pSQL($value) . '"'; + $result = Db::getInstance()->getRow($sql); + + if ($result) { + $model = new static(); + $model->hydrate($result); + return $model; + } + + return null; + } + + /** + * Find model by field or throw exception + * + * @param string $field + * @param mixed $value + * @return static + * @throws Exception + */ + public static function findByOrFail($field, $value) + { + $model = static::findBy($field, $value); + if (!$model) { + throw new Exception('Model not found with ' . $field . ': ' . $value); + } + return $model; + } + + /** + * Get all models + * + * @param string $orderBy + * @param string $orderWay + * @return array + */ + public static function all($orderBy = null, $orderWay = 'ASC') + { + $sql = 'SELECT * FROM `' . _DB_PREFIX_ . static::$definition['table'] . '`'; + + if ($orderBy) { + $sql .= ' ORDER BY `' . pSQL($orderBy) . '` ' . pSQL($orderWay); + } + + $results = Db::getInstance()->executeS($sql); + $models = []; + + foreach ($results as $result) { + $model = new static(); + $model->hydrate($result); + $models[] = $model; + } + + return $models; + } + + /** + * Get models with pagination + * + * @param int $page + * @param int $perPage + * @param string $orderBy + * @param string $orderWay + * @return array + */ + public static function paginate($page = 1, $perPage = 20, $orderBy = null, $orderWay = 'ASC') + { + $offset = ($page - 1) * $perPage; + + $sql = 'SELECT * FROM `' . _DB_PREFIX_ . static::$definition['table'] . '`'; + + if ($orderBy) { + $sql .= ' ORDER BY `' . pSQL($orderBy) . '` ' . pSQL($orderWay); + } + + $sql .= ' LIMIT ' . (int) $perPage . ' OFFSET ' . (int) $offset; + + $results = Db::getInstance()->executeS($sql); + $models = []; + + foreach ($results as $result) { + $model = new static(); + $model->hydrate($result); + $models[] = $model; + } + + return $models; + } + + /** + * Count total models + * + * @return int + */ + public static function count() + { + $sql = 'SELECT COUNT(*) as total FROM `' . _DB_PREFIX_ . static::$definition['table'] . '`'; + $result = Db::getInstance()->getRow($sql); + return (int) $result['total']; + } + + /** + * Check if model exists + * + * @param int $id + * @return bool + */ + public static function exists($id) + { + $sql = 'SELECT COUNT(*) as total FROM `' . _DB_PREFIX_ . static::$definition['table'] . '` WHERE `' . static::$definition['primary'] . '` = ' . (int) $id; + $result = Db::getInstance()->getRow($sql); + return (int) $result['total'] > 0; + } + + /** + * Fill model with attributes + * + * @param array $attributes + * @return $this + */ + public function fill(array $attributes) + { + foreach ($attributes as $key => $value) { + if (in_array($key, $this->fillable) || empty($this->fillable)) { + $this->{$key} = $value; + } + } + + return $this; + } + + /** + * Update model + * + * @param array $attributes + * @return bool + */ + public function update(array $attributes = []) + { + $this->fill($attributes); + + if ($this->timestamps && property_exists($this, $this->updatedAtField)) { + $this->{$this->updatedAtField} = date('Y-m-d H:i:s'); + } + + return parent::update(); + } + + /** + * Delete model + * + * @return bool + */ + public function delete() + { + return parent::delete(); + } + + /** + * Validate model + * + * @param bool $die + * @param bool $error_return + * @return bool|string + */ + public function validate($die = true, $error_return = false) + { + $this->errors = []; + + // Validate required fields + foreach (static::$definition['fields'] as $field => $rules) { + if (isset($rules['required']) && $rules['required']) { + if (!isset($this->{$field}) || empty($this->{$field})) { + $this->errors[] = 'Field ' . $field . ' is required'; + } + } + } + + // Validate custom rules + if (!empty(static::$rules)) { + foreach (static::$rules as $field => $rules) { + if (isset($this->{$field})) { + $this->validateField($field, $this->{$field}, $rules); + } + } + } + + if (!empty($this->errors)) { + if ($die) { + throw new Exception(implode(', ', $this->errors)); + } + return $error_return ? implode(', ', $this->errors) : false; + } + + return true; + } + + /** + * Validate single field + * + * @param string $field + * @param mixed $value + * @param array $rules + */ + protected function validateField($field, $value, $rules) + { + foreach ($rules as $rule) { + switch ($rule) { + case 'email': + if (!Validate::isEmail($value)) { + $this->errors[] = 'Field ' . $field . ' must be a valid email'; + } + break; + + case 'url': + if (!Validate::isUrl($value)) { + $this->errors[] = 'Field ' . $field . ' must be a valid URL'; + } + break; + + case 'numeric': + if (!is_numeric($value)) { + $this->errors[] = 'Field ' . $field . ' must be numeric'; + } + break; + + case 'integer': + if (!is_int($value) && !ctype_digit($value)) { + $this->errors[] = 'Field ' . $field . ' must be an integer'; + } + break; + + case 'boolean': + if (!is_bool($value) && !in_array($value, [0, 1, '0', '1'])) { + $this->errors[] = 'Field ' . $field . ' must be boolean'; + } + break; + } + } + } + + /** + * Get validation errors + * + * @return array + */ + public function getValidationErrors() + { + return $this->errors; + } + + /** + * Check if model has errors + * + * @return bool + */ + public function hasErrors() + { + return !empty($this->errors); + } + + /** + * Get model as array + * + * @return array + */ + public function toArray() + { + $array = []; + + foreach (get_object_vars($this) as $key => $value) { + if (!in_array($key, $this->hidden)) { + $array[$key] = $value; + } + } + + return $array; + } + + /** + * Get model as JSON + * + * @return string + */ + public function toJson() + { + return json_encode($this->toArray()); + } + + /** + * Define one-to-one relationship + * + * @param string $related + * @param string $foreignKey + * @param string $localKey + * @return mixed + */ + public function hasOne($related, $foreignKey = null, $localKey = null) + { + $foreignKey = $foreignKey ?: $this->getForeignKey(); + $localKey = $localKey ?: $this->getKeyName(); + + $relatedModel = new $related(); + return $relatedModel->findBy($foreignKey, $this->{$localKey}); + } + + /** + * Define one-to-many relationship + * + * @param string $related + * @param string $foreignKey + * @param string $localKey + * @return array + */ + public function hasMany($related, $foreignKey = null, $localKey = null) + { + $foreignKey = $foreignKey ?: $this->getForeignKey(); + $localKey = $localKey ?: $this->getKeyName(); + + $relatedModel = new $related(); + $sql = 'SELECT * FROM `' . _DB_PREFIX_ . $relatedModel::$definition['table'] . '` WHERE `' . pSQL($foreignKey) . '` = ' . (int) $this->{$localKey}; + $results = Db::getInstance()->executeS($sql); + + $models = []; + foreach ($results as $result) { + $model = new $related(); + $model->hydrate($result); + $models[] = $model; + } + + return $models; + } + + /** + * Define belongs-to relationship + * + * @param string $related + * @param string $foreignKey + * @param string $ownerKey + * @return mixed + */ + public function belongsTo($related, $foreignKey = null, $ownerKey = null) + { + $foreignKey = $foreignKey ?: $this->getForeignKey(); + $ownerKey = $ownerKey ?: 'id'; + + $relatedModel = new $related(); + return $relatedModel->find($this->{$foreignKey}); + } + + /** + * Define many-to-many relationship + * + * @param string $related + * @param string $table + * @param string $foreignPivotKey + * @param string $relatedPivotKey + * @param string $parentKey + * @param string $relatedKey + * @return array + */ + public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null) + { + $table = $table ?: $this->joiningTable($related); + $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey(); + $relatedPivotKey = $relatedPivotKey ?: $this->getRelatedForeignKey($related); + $parentKey = $parentKey ?: $this->getKeyName(); + $relatedKey = $relatedKey ?: 'id'; + + $relatedModel = new $related(); + + $sql = 'SELECT r.* FROM `' . _DB_PREFIX_ . $table . '` p + JOIN `' . _DB_PREFIX_ . $relatedModel::$definition['table'] . '` r ON p.`' . pSQL($relatedPivotKey) . '` = r.`' . pSQL($relatedKey) . '` + WHERE p.`' . pSQL($foreignPivotKey) . '` = ' . (int) $this->{$parentKey}; + + $results = Db::getInstance()->executeS($sql); + $models = []; + + foreach ($results as $result) { + $model = new $related(); + $model->hydrate($result); + $models[] = $model; + } + + return $models; + } + + /** + * Get foreign key name + * + * @return string + */ + protected function getForeignKey() + { + return strtolower(class_basename($this)) . '_id'; + } + + /** + * Get related foreign key name + * + * @param string $related + * @return string + */ + protected function getRelatedForeignKey($related) + { + return strtolower(class_basename($related)) . '_id'; + } + + /** + * Get joining table name + * + * @param string $related + * @return string + */ + protected function joiningTable($related) + { + $models = [strtolower(class_basename($this)), strtolower(class_basename($related))]; + sort($models); + return implode('_', $models); + } + + /** + * Get key name + * + * @return string + */ + public function getKeyName() + { + return $this->primaryKey; + } + + /** + * Get key value + * + * @return mixed + */ + public function getKey() + { + return $this->{$this->getKeyName()}; + } + + /** + * Check if model is new + * + * @return bool + */ + public function isNew() + { + return !$this->getKey(); + } + + /** + * Refresh model from database + * + * @return $this + */ + public function refresh() + { + if ($this->getKey()) { + $fresh = static::find($this->getKey()); + if ($fresh) { + $this->hydrate($fresh->toArray()); + } + } + + return $this; + } + + /** + * Touch model (update timestamps) + * + * @return bool + */ + public function touch() + { + if ($this->timestamps && property_exists($this, $this->updatedAtField)) { + $this->{$this->updatedAtField} = date('Y-m-d H:i:s'); + return $this->update(); + } + + return false; + } + + /** + * Get table name + * + * @return string + */ + public function getTable() + { + return static::$definition['table']; + } + + /** + * Get connection + * + * @return Db + */ + public function getConnection() + { + return Db::getInstance(); + } +} \ No newline at end of file