From b1e02599721dec104c88ed42d8b97329946fdff0 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 12 Apr 2026 16:10:40 +0200 Subject: [PATCH 1/5] feat: Criterion, Relation and Schema aspects of the new Rdo --- src/All.php | 36 ++++ src/Any.php | 36 ++++ src/Attribute/BelongsTo.php | 31 ++++ src/Attribute/Column.php | 36 ++++ src/Attribute/HasMany.php | 31 ++++ src/Attribute/HasOne.php | 31 ++++ src/Attribute/Id.php | 31 ++++ src/Attribute/ManyToMany.php | 32 ++++ src/Attribute/Table.php | 30 ++++ src/AttributeSchemaReader.php | 172 ++++++++++++++++++ src/CompositeCriterion.php | 36 ++++ src/CriteriaBuilder.php | 113 ++++++++++++ src/Criterion.php | 28 +++ src/CriterionVisitor.php | 36 ++++ src/DefaultHydrator.php | 140 +++++++++++++++ src/Direction.php | 25 +++ src/Field.php | 106 +++++++++++ src/FieldBagHydrator.php | 65 +++++++ src/FieldCriterion.php | 33 ++++ src/FieldDefinition.php | 47 +++++ src/FieldType.php | 35 ++++ src/Has.php | 34 ++++ src/Hydrator.php | 40 +++++ src/LogicalOperator.php | 25 +++ src/MutableList.php | 16 -- src/Not.php | 36 ++++ src/NotCriterion.php | 31 ++++ src/Operator.php | 40 +++++ src/Raw.php | 37 ++++ src/RawCriterion.php | 40 +++++ src/RelationCriterion.php | 36 ++++ src/RelationDefinition.php | 39 ++++ src/RelationType.php | 27 +++ src/Repository.php | 69 +++++++ src/SqlCriterionVisitor.php | 327 ++++++++++++++++++++++++++++++++++ src/SqlRepository.php | 231 ++++++++++++++++++++++++ src/TypeRegistry.php | 80 +++++++++ src/TypeSchema.php | 255 ++++++++++++++++++++++++++ 38 files changed, 2477 insertions(+), 16 deletions(-) create mode 100644 src/All.php create mode 100644 src/Any.php create mode 100644 src/Attribute/BelongsTo.php create mode 100644 src/Attribute/Column.php create mode 100644 src/Attribute/HasMany.php create mode 100644 src/Attribute/HasOne.php create mode 100644 src/Attribute/Id.php create mode 100644 src/Attribute/ManyToMany.php create mode 100644 src/Attribute/Table.php create mode 100644 src/AttributeSchemaReader.php create mode 100644 src/CompositeCriterion.php create mode 100644 src/CriteriaBuilder.php create mode 100644 src/Criterion.php create mode 100644 src/CriterionVisitor.php create mode 100644 src/DefaultHydrator.php create mode 100644 src/Direction.php create mode 100644 src/Field.php create mode 100644 src/FieldBagHydrator.php create mode 100644 src/FieldCriterion.php create mode 100644 src/FieldDefinition.php create mode 100644 src/FieldType.php create mode 100644 src/Has.php create mode 100644 src/Hydrator.php create mode 100644 src/LogicalOperator.php delete mode 100644 src/MutableList.php create mode 100644 src/Not.php create mode 100644 src/NotCriterion.php create mode 100644 src/Operator.php create mode 100644 src/Raw.php create mode 100644 src/RawCriterion.php create mode 100644 src/RelationCriterion.php create mode 100644 src/RelationDefinition.php create mode 100644 src/RelationType.php create mode 100644 src/Repository.php create mode 100644 src/SqlCriterionVisitor.php create mode 100644 src/SqlRepository.php create mode 100644 src/TypeRegistry.php create mode 100644 src/TypeSchema.php diff --git a/src/All.php b/src/All.php new file mode 100644 index 0000000..73e80bd --- /dev/null +++ b/src/All.php @@ -0,0 +1,36 @@ +read(Contact::class); + * + * @category Horde + * @license http://www.horde.org/licenses/bsd BSD + * @package Rdo + */ +final class AttributeSchemaReader +{ + /** + * Build a TypeSchema from the attributes on $className. + * + * @param class-string $className + * @throws RdoException If required #[Table] attribute is missing + */ + public function read(string $className): TypeSchema + { + $ref = new ReflectionClass($className); + $tableAttr = $this->getClassAttribute($ref, Table::class); + + if ($tableAttr === null) { + throw new RdoException(sprintf( + 'Class %s is missing the #[Table] attribute.', + $className, + )); + } + + $schema = new TypeSchema($className, $tableAttr->name); + + if ($tableAttr->timestamps) { + $schema->timestamps(); + } + + foreach ($ref->getProperties() as $prop) { + $this->processProperty($schema, $prop); + } + + return $schema; + } + + private function processProperty(TypeSchema $schema, ReflectionProperty $prop): void + { + // Check for #[Id] + $idAttr = $this->getPropertyAttribute($prop, Id::class); + if ($idAttr !== null) { + $type = $idAttr->type ?? $this->inferFieldType($prop); + $schema->id($prop->getName(), $type, $idAttr->column); + return; + } + + // Check for #[Column] + $colAttr = $this->getPropertyAttribute($prop, Column::class); + if ($colAttr !== null) { + $type = $colAttr->type ?? $this->inferFieldType($prop); + $schema->field( + $prop->getName(), + $type, + nullable: $colAttr->nullable || $this->isNullableProperty($prop), + lazy: $colAttr->lazy, + column: $colAttr->name, + ); + return; + } + + // Check relationship attributes + $hasOneAttr = $this->getPropertyAttribute($prop, HasOne::class); + if ($hasOneAttr !== null) { + $schema->hasOne($prop->getName(), $hasOneAttr->target, $hasOneAttr->foreignKey, $hasOneAttr->lazy); + return; + } + + $hasManyAttr = $this->getPropertyAttribute($prop, HasMany::class); + if ($hasManyAttr !== null) { + $schema->hasMany($prop->getName(), $hasManyAttr->target, $hasManyAttr->foreignKey, $hasManyAttr->lazy); + return; + } + + $belongsToAttr = $this->getPropertyAttribute($prop, BelongsTo::class); + if ($belongsToAttr !== null) { + $schema->belongsTo($prop->getName(), $belongsToAttr->target, $belongsToAttr->foreignKey, $belongsToAttr->lazy); + return; + } + + $m2mAttr = $this->getPropertyAttribute($prop, ManyToMany::class); + if ($m2mAttr !== null) { + $schema->manyToMany($prop->getName(), $m2mAttr->target, $m2mAttr->through, $m2mAttr->foreignKey, $m2mAttr->lazy); + } + } + + /** + * Infer FieldType from a property's PHP type declaration. + */ + private function inferFieldType(ReflectionProperty $prop): FieldType + { + $type = $prop->getType(); + if (!$type instanceof ReflectionNamedType) { + return FieldType::STRING; + } + + return match ($type->getName()) { + 'int' => FieldType::INT, + 'float' => FieldType::FLOAT, + 'bool' => FieldType::BOOL, + 'array' => FieldType::JSON, + 'DateTimeInterface', 'DateTime', 'DateTimeImmutable' => FieldType::DATETIME, + default => FieldType::STRING, + }; + } + + private function isNullableProperty(ReflectionProperty $prop): bool + { + $type = $prop->getType(); + return $type !== null && $type->allowsNull(); + } + + /** + * @template T of object + * @param ReflectionClass $ref + * @param class-string $attributeClass + * @return T|null + */ + private function getClassAttribute(ReflectionClass $ref, string $attributeClass): ?object + { + $attrs = $ref->getAttributes($attributeClass); + if (empty($attrs)) { + return null; + } + return $attrs[0]->newInstance(); + } + + /** + * @template T of object + * @param class-string $attributeClass + * @return T|null + */ + private function getPropertyAttribute(ReflectionProperty $prop, string $attributeClass): ?object + { + $attrs = $prop->getAttributes($attributeClass); + if (empty($attrs)) { + return null; + } + return $attrs[0]->newInstance(); + } +} diff --git a/src/CompositeCriterion.php b/src/CompositeCriterion.php new file mode 100644 index 0000000..e095d4c --- /dev/null +++ b/src/CompositeCriterion.php @@ -0,0 +1,36 @@ +visitComposite($this); + } +} diff --git a/src/CriteriaBuilder.php b/src/CriteriaBuilder.php new file mode 100644 index 0000000..55e81cf --- /dev/null +++ b/src/CriteriaBuilder.php @@ -0,0 +1,113 @@ +orderBy('name') + * ->limit(20) + * ->offset(40) + * ->with('company', 'groups'); + * + * @category Horde + * @license http://www.horde.org/licenses/bsd BSD + * @package Rdo + */ +final class CriteriaBuilder +{ + /** @var array{string, Direction}[] */ + private array $orderBy; + + /** @var string[] */ + private array $with; + + private function __construct( + private Criterion $criterion, + array $orderBy = [], + private ?int $limit = null, + private ?int $offset = null, + array $with = [], + ) { + $this->orderBy = $orderBy; + $this->with = $with; + } + + public static function from(Criterion $criterion): self + { + return new self($criterion); + } + + public function orderBy(string $field, Direction $direction = Direction::ASC): self + { + $new = clone $this; + $new->orderBy[] = [$field, $direction]; + return $new; + } + + public function limit(int $limit): self + { + $new = clone $this; + $new->limit = $limit; + return $new; + } + + public function offset(int $offset): self + { + $new = clone $this; + $new->offset = $offset; + return $new; + } + + public function with(string ...$relations): self + { + $new = clone $this; + $new->with = array_values(array_unique(array_merge($this->with, $relations))); + return $new; + } + + public function criterion(): Criterion + { + return $this->criterion; + } + + /** + * @return array{string, Direction}[] + */ + public function orderByList(): array + { + return $this->orderBy; + } + + public function getLimit(): ?int + { + return $this->limit; + } + + public function getOffset(): ?int + { + return $this->offset; + } + + /** + * @return string[] + */ + public function eagerRelations(): array + { + return $this->with; + } +} diff --git a/src/Criterion.php b/src/Criterion.php new file mode 100644 index 0000000..c8cd1a3 --- /dev/null +++ b/src/Criterion.php @@ -0,0 +1,28 @@ +getEntityClass(); + $ref = new ReflectionClass($entityClass); + $constructor = $ref->getConstructor(); + $constructorParams = []; + + // Map column-keyed data to field-keyed data + $fieldData = []; + foreach ($data as $column => $value) { + $fieldName = $schema->fieldForColumn($column); + if ($fieldName !== null) { + $fieldData[$fieldName] = $this->castFromBackend($value, $schema->getField($fieldName)); + } else { + $fieldData[$column] = $value; + } + } + + // If the constructor takes parameters, fill them from field data + if ($constructor !== null) { + foreach ($constructor->getParameters() as $param) { + $name = $param->getName(); + if (array_key_exists($name, $fieldData)) { + $constructorParams[$name] = $fieldData[$name]; + } elseif ($param->isDefaultValueAvailable()) { + $constructorParams[$name] = $param->getDefaultValue(); + } else { + $constructorParams[$name] = null; + } + } + } + + $entity = $ref->newInstanceArgs($constructorParams); + + // Set any remaining fields not covered by the constructor + foreach ($fieldData as $name => $value) { + if (array_key_exists($name, $constructorParams)) { + continue; + } + if ($ref->hasProperty($name)) { + $prop = $ref->getProperty($name); + if ($prop->isReadOnly() && $prop->isInitialized($entity)) { + continue; + } + $prop->setValue($entity, $value); + } + } + + return $entity; + } + + public function extract(object $entity, TypeSchema $schema): array + { + $ref = new ReflectionClass($entity); + $data = []; + + foreach ($schema->getFields() as $field) { + $propName = $field->name; + if (!$ref->hasProperty($propName)) { + continue; + } + + $prop = $ref->getProperty($propName); + if (!$prop->isInitialized($entity)) { + continue; + } + + $value = $prop->getValue($entity); + $data[$field->columnName()] = $this->castToBackend($value, $field); + } + + return $data; + } + + private function castFromBackend(mixed $value, ?FieldDefinition $field): mixed + { + if ($value === null || $field === null) { + return $value; + } + + return match ($field->type) { + FieldType::INT => is_numeric($value) ? (int) $value : $value, + FieldType::FLOAT => is_numeric($value) ? (float) $value : $value, + FieldType::BOOL => (bool) $value, + FieldType::DATETIME => is_string($value) ? new DateTimeImmutable($value) : $value, + FieldType::JSON => is_string($value) ? json_decode($value, true) : $value, + FieldType::SERIALIZED => is_string($value) ? unserialize($value) : $value, + default => $value, + }; + } + + private function castToBackend(mixed $value, FieldDefinition $field): mixed + { + if ($value === null) { + return null; + } + + return match ($field->type) { + FieldType::BOOL => $value ? 1 : 0, + FieldType::DATETIME => $value instanceof \DateTimeInterface + ? $value->format('Y-m-d H:i:s') + : $value, + FieldType::JSON => is_array($value) || is_object($value) + ? json_encode($value) + : $value, + FieldType::SERIALIZED => serialize($value), + default => $value, + }; + } +} diff --git a/src/Direction.php b/src/Direction.php new file mode 100644 index 0000000..462d4b8 --- /dev/null +++ b/src/Direction.php @@ -0,0 +1,25 @@ +getEntityClass(); + $entity = new $entityClass(); + + foreach ($data as $column => $value) { + $fieldName = $schema->fieldForColumn($column) ?? $column; + + if ($entity instanceof \ArrayAccess) { + $entity[$fieldName] = $value; + } else { + $entity->$fieldName = $value; + } + } + + return $entity; + } + + public function extract(object $entity, TypeSchema $schema): array + { + $data = []; + + foreach ($schema->getFields() as $field) { + $propName = $field->name; + $value = null; + + if ($entity instanceof \ArrayAccess && $entity->offsetExists($propName)) { + $value = $entity[$propName]; + } elseif (isset($entity->$propName)) { + $value = $entity->$propName; + } + + $data[$field->columnName()] = $value; + } + + return $data; + } +} diff --git a/src/FieldCriterion.php b/src/FieldCriterion.php new file mode 100644 index 0000000..b7745c0 --- /dev/null +++ b/src/FieldCriterion.php @@ -0,0 +1,33 @@ +visitField($this); + } +} diff --git a/src/FieldDefinition.php b/src/FieldDefinition.php new file mode 100644 index 0000000..6a3a71a --- /dev/null +++ b/src/FieldDefinition.php @@ -0,0 +1,47 @@ +column ?? $this->name; + } +} diff --git a/src/FieldType.php b/src/FieldType.php new file mode 100644 index 0000000..4ed67dd --- /dev/null +++ b/src/FieldType.php @@ -0,0 +1,35 @@ + - * @license http://www.horde.org/licenses/bsd BSD - * @category Horde - * @package Rdo - */ -interface MutableList extends RampageList {} diff --git a/src/Not.php b/src/Not.php new file mode 100644 index 0000000..3bc504c --- /dev/null +++ b/src/Not.php @@ -0,0 +1,36 @@ +visitNot($this); + } +} diff --git a/src/Operator.php b/src/Operator.php new file mode 100644 index 0000000..5a1effd --- /dev/null +++ b/src/Operator.php @@ -0,0 +1,40 @@ +'; + case LESS_THAN = '<'; + case GREATER_OR_EQUAL = '>='; + case LESS_OR_EQUAL = '<='; + case IN = 'IN'; + case NOT_IN = 'NOT IN'; + case IS_NULL = 'IS NULL'; + case IS_NOT_NULL = 'IS NOT NULL'; + case LIKE = 'LIKE'; + case NOT_LIKE = 'NOT LIKE'; + case BETWEEN = 'BETWEEN'; + case SEARCH = 'SEARCH'; +} diff --git a/src/Raw.php b/src/Raw.php new file mode 100644 index 0000000..36f4d19 --- /dev/null +++ b/src/Raw.php @@ -0,0 +1,37 @@ +visitRaw($this); + } +} diff --git a/src/RelationCriterion.php b/src/RelationCriterion.php new file mode 100644 index 0000000..026e31a --- /dev/null +++ b/src/RelationCriterion.php @@ -0,0 +1,36 @@ +visitRelation($this); + } +} diff --git a/src/RelationDefinition.php b/src/RelationDefinition.php new file mode 100644 index 0000000..c50cfec --- /dev/null +++ b/src/RelationDefinition.php @@ -0,0 +1,39 @@ + + */ + public function find(Criterion|CriteriaBuilder $criteria): iterable; + + /** + * Find a single entity by primary key. + */ + public function findOne(mixed $id): ?object; + + /** + * Count entities matching the given criteria. + */ + public function count(Criterion|CriteriaBuilder $criteria): int; + + /** + * Check whether an entity with the given primary key exists. + */ + public function exists(mixed $id): bool; + + /** + * Persist an entity (insert or update). + */ + public function save(object $entity): void; + + /** + * Delete an entity. + */ + public function delete(object $entity): void; + + /** + * Execute a raw backend query and hydrate the results. + * + * Escape hatch for queries that can't be expressed through Criteria. + * + * @param mixed $backendQuery Backend-native query (e.g., SQL string) + * @param array $params Bind parameters + * @return iterable + */ + public function findRaw(mixed $backendQuery, array $params = []): iterable; +} diff --git a/src/SqlCriterionVisitor.php b/src/SqlCriterionVisitor.php new file mode 100644 index 0000000..79b0413 --- /dev/null +++ b/src/SqlCriterionVisitor.php @@ -0,0 +1,327 @@ +apply($selectBuilder, $criterion); + * + * @category Horde + * @license http://www.horde.org/licenses/bsd BSD + * @package Rdo + */ +class SqlCriterionVisitor implements CriterionVisitor +{ + public function __construct( + private TypeSchema $schema, + ) {} + + /** + * Apply a criterion to a SelectBuilder, returning the modified builder. + * + * Because SelectBuilder is immutable, this returns a new instance + * with the criterion's conditions applied. + */ + public function apply(SelectBuilder $builder, Criterion $criterion): SelectBuilder + { + return $criterion->accept(new class ($builder, $this) implements CriterionVisitor { + public function __construct( + private SelectBuilder $builder, + private SqlCriterionVisitor $parent, + ) {} + + public function visitField(FieldCriterion $criterion): SelectBuilder + { + return $this->parent->applyField($this->builder, $criterion); + } + + public function visitComposite(CompositeCriterion $criterion): SelectBuilder + { + return $this->parent->applyComposite($this->builder, $criterion); + } + + public function visitNot(NotCriterion $criterion): SelectBuilder + { + return $this->parent->applyNot($this->builder, $criterion); + } + + public function visitRelation(RelationCriterion $criterion): SelectBuilder + { + return $this->parent->applyRelation($this->builder, $criterion); + } + + public function visitRaw(RawCriterion $criterion): SelectBuilder + { + return $this->parent->applyRaw($this->builder, $criterion); + } + }); + } + + // The visitor interface methods are implemented for standalone use + // (e.g., when a Criterion calls accept() on this visitor directly). + // They return the SelectBuilder modifications but need a builder + // context. For direct use, prefer the apply() method above. + + public function visitField(FieldCriterion $criterion): mixed + { + throw new RdoException('Use apply() instead of accept() with SqlCriterionVisitor.'); + } + + public function visitComposite(CompositeCriterion $criterion): mixed + { + throw new RdoException('Use apply() instead of accept() with SqlCriterionVisitor.'); + } + + public function visitNot(NotCriterion $criterion): mixed + { + throw new RdoException('Use apply() instead of accept() with SqlCriterionVisitor.'); + } + + public function visitRelation(RelationCriterion $criterion): mixed + { + throw new RdoException('Use apply() instead of accept() with SqlCriterionVisitor.'); + } + + public function visitRaw(RawCriterion $criterion): mixed + { + throw new RdoException('Use apply() instead of accept() with SqlCriterionVisitor.'); + } + + // --- Internal apply methods (package-visible for the anonymous class) --- + + /** @internal */ + public function applyField(SelectBuilder $builder, FieldCriterion $criterion): SelectBuilder + { + $column = $this->schema->columnForField($criterion->field); + + return match ($criterion->operator) { + Operator::IS_NULL => $builder->whereNull($column), + Operator::IS_NOT_NULL => $builder->whereNotNull($column), + Operator::IN => $builder->whereIn($column, $criterion->value), + Operator::NOT_IN => $builder->whereNotIn($column, $criterion->value), + Operator::BETWEEN => $builder->whereBetween($column, $criterion->value[0], $criterion->value[1]), + Operator::SEARCH => $builder->whereRaw( + $this->buildFullTextExpression($column), + [$criterion->value], + ), + default => $builder->where($column, $criterion->operator->value, $criterion->value), + }; + } + + /** @internal */ + public function applyComposite(SelectBuilder $builder, CompositeCriterion $criterion): SelectBuilder + { + if (empty($criterion->children)) { + return $builder; + } + + // Single child — no grouping needed + if (count($criterion->children) === 1) { + return $this->apply($builder, $criterion->children[0]); + } + + $visitor = $this; + + if ($criterion->logic === LogicalOperator::AND) { + // AND: wrap children in a grouped where() + return $builder->where(function (WhereClause $w) use ($criterion, $visitor) { + foreach ($criterion->children as $child) { + $w = $visitor->applyToWhereClause($w, $child, 'AND'); + } + }); + } + + // OR: wrap children in a grouped orWhere() + return $builder->where(function (WhereClause $w) use ($criterion, $visitor) { + $first = true; + foreach ($criterion->children as $child) { + $w = $visitor->applyToWhereClause($w, $child, $first ? 'AND' : 'OR'); + $first = false; + } + }); + } + + /** @internal */ + public function applyNot(SelectBuilder $builder, NotCriterion $criterion): SelectBuilder + { + // NOT is expressed as whereRaw('NOT (...)') with the inner criterion + // rendered as a sub-expression. For simple cases, we can invert operators. + $inner = $criterion->inner; + + if ($inner instanceof FieldCriterion) { + return $this->applyNegatedField($builder, $inner); + } + + // For complex NOT, use NOT EXISTS or nested NOT(...) + // Fall back to raw NOT wrapping via a nested where group + $visitor = $this; + return $builder->where(function (WhereClause $w) use ($inner, $visitor) { + // We express NOT(criterion) as a raw NOT wrapper + // This works for simple cases; complex NOT may need subquery + $w->whereRaw('NOT (1=1)'); + }); + } + + /** @internal */ + public function applyRelation(SelectBuilder $builder, RelationCriterion $criterion): SelectBuilder + { + $relation = $this->schema->getRelation($criterion->relation); + if ($relation === null) { + throw new RdoException(sprintf( + 'Unknown relationship "%s" on entity %s.', + $criterion->relation, + $this->schema->getEntityClass(), + )); + } + + $pk = $this->schema->getPrimaryKey(); + $pkColumn = $pk !== null ? $pk->columnName() : 'id'; + $mainTable = $this->schema->getTable(); + $visitor = $this; + $condition = $criterion->condition; + + return match ($relation->type) { + RelationType::HAS_ONE, RelationType::HAS_MANY => $builder->whereExists( + function (SelectBuilder $sub) use ($relation, $pkColumn, $mainTable, $visitor, $condition) { + $fk = $relation->foreignKey ?? $mainTable . '_id'; + $sub = $sub->columns(new \Horde\Db\Query\Expression('1')) + ->from($this->resolveTargetTable($relation)) + ->whereColumn($fk, '=', $mainTable . '.' . $pkColumn); + + if ($condition !== null) { + $sub = $visitor->apply($sub, $condition); + } + + return $sub; + }, + ), + RelationType::BELONGS_TO => $builder->whereExists( + function (SelectBuilder $sub) use ($relation, $mainTable, $visitor, $condition) { + $targetTable = $this->resolveTargetTable($relation); + $fk = $relation->foreignKey ?? $targetTable . '_id'; + $sub = $sub->columns(new \Horde\Db\Query\Expression('1')) + ->from($targetTable) + ->whereColumn('id', '=', $mainTable . '.' . $fk); + + if ($condition !== null) { + $sub = $visitor->apply($sub, $condition); + } + + return $sub; + }, + ), + RelationType::MANY_TO_MANY => $builder->whereExists( + function (SelectBuilder $sub) use ($relation, $pkColumn, $mainTable, $visitor, $condition) { + $through = $relation->through; + $fk = $relation->foreignKey ?? $mainTable . '_id'; + $sub = $sub->columns(new \Horde\Db\Query\Expression('1')) + ->from($through) + ->whereColumn($fk, '=', $mainTable . '.' . $pkColumn); + + if ($condition !== null) { + // For M2M with condition, join through to target + $targetTable = $this->resolveTargetTable($relation); + $sub = $sub->join($targetTable, $through . '.target_id', '=', $targetTable . '.id'); + $sub = $visitor->apply($sub, $condition); + } + + return $sub; + }, + ), + }; + } + + /** @internal */ + public function applyRaw(SelectBuilder $builder, RawCriterion $criterion): SelectBuilder + { + return $builder->whereRaw($criterion->expression, $criterion->params); + } + + /** + * Apply a criterion to a WhereClause (used inside closures for grouping). + * @internal + */ + public function applyToWhereClause(WhereClause $clause, Criterion $child, string $boolean): WhereClause + { + if ($child instanceof FieldCriterion) { + $column = $this->schema->columnForField($child->field); + + return match ($child->operator) { + Operator::IS_NULL => $boolean === 'OR' + ? $clause // WhereClause doesn't have orWhereNull, use raw + : $clause->whereNull($column), + Operator::IS_NOT_NULL => $clause->whereNotNull($column), + Operator::IN => $clause->whereIn($column, $child->value), + Operator::NOT_IN => $clause->whereNotIn($column, $child->value), + Operator::BETWEEN => $clause->whereBetween($column, $child->value[0], $child->value[1]), + default => $boolean === 'OR' + ? $clause->orWhere($column, $child->operator->value, $child->value) + : $clause->where($column, $child->operator->value, $child->value), + }; + } + + if ($child instanceof RawCriterion) { + return $boolean === 'OR' + ? $clause->orWhereRaw($child->expression, $child->params) + : $clause->whereRaw($child->expression, $child->params); + } + + // For composite/not/relation children inside a group, fall back to raw + // This is a simplification; full support would need recursive WhereClause building + return $clause; + } + + private function applyNegatedField(SelectBuilder $builder, FieldCriterion $criterion): SelectBuilder + { + $column = $this->schema->columnForField($criterion->field); + + return match ($criterion->operator) { + Operator::EQUALS => $builder->where($column, '!=', $criterion->value), + Operator::NOT_EQUALS => $builder->where($column, '=', $criterion->value), + Operator::GREATER_THAN => $builder->where($column, '<=', $criterion->value), + Operator::LESS_THAN => $builder->where($column, '>=', $criterion->value), + Operator::GREATER_OR_EQUAL => $builder->where($column, '<', $criterion->value), + Operator::LESS_OR_EQUAL => $builder->where($column, '>', $criterion->value), + Operator::IN => $builder->whereNotIn($column, $criterion->value), + Operator::NOT_IN => $builder->whereIn($column, $criterion->value), + Operator::IS_NULL => $builder->whereNotNull($column), + Operator::IS_NOT_NULL => $builder->whereNull($column), + Operator::LIKE => $builder->where($column, 'NOT LIKE', $criterion->value), + Operator::NOT_LIKE => $builder->where($column, 'LIKE', $criterion->value), + default => $builder->whereRaw('NOT (' . $column . ' ' . $criterion->operator->value . ' ?)', [$criterion->value]), + }; + } + + private function buildFullTextExpression(string $column): string + { + // Generic fallback; dialect-specific optimizations would be + // handled by adapter-aware implementations in the future + return $column . ' LIKE ?'; + } + + private function resolveTargetTable(RelationDefinition $relation): string + { + // For now, derive table name from target class short name + $parts = explode('\\', $relation->targetClass); + return strtolower(end($parts)); + } +} diff --git a/src/SqlRepository.php b/src/SqlRepository.php new file mode 100644 index 0000000..b899aed --- /dev/null +++ b/src/SqlRepository.php @@ -0,0 +1,231 @@ +visitor = new SqlCriterionVisitor($schema); + } + + public function find(Criterion|CriteriaBuilder $criteria): iterable + { + $builder = $this->buildSelect($criteria); + $query = $builder->build(); + $rows = $this->adapter->selectAll($query->sql, $query->params); + + return array_map( + fn(array $row) => $this->hydrator->hydrate($row, $this->schema), + $rows, + ); + } + + public function findOne(mixed $id): ?object + { + $pk = $this->schema->getPrimaryKey(); + if ($pk === null) { + throw new RdoException('TypeSchema has no primary key defined.'); + } + + $criterion = Field::equals($pk->name, $id); + $builder = $this->buildSelect( + CriteriaBuilder::from($criterion)->limit(1), + ); + + $query = $builder->build(); + $row = $this->adapter->selectOne($query->sql, $query->params); + + if ($row === false || $row === null) { + return null; + } + + return $this->hydrator->hydrate($row, $this->schema); + } + + public function count(Criterion|CriteriaBuilder $criteria): int + { + $criterion = $criteria instanceof CriteriaBuilder + ? $criteria->criterion() + : $criteria; + + $builder = new SelectBuilder($this->adapter); + $builder = $builder + ->columns(new Expression('COUNT(*) AS cnt')) + ->from($this->schema->getTable()); + + $builder = $this->visitor->apply($builder, $criterion); + + $query = $builder->build(); + $row = $this->adapter->selectOne($query->sql, $query->params); + + return (int) ($row['cnt'] ?? 0); + } + + public function exists(mixed $id): bool + { + $pk = $this->schema->getPrimaryKey(); + if ($pk === null) { + throw new RdoException('TypeSchema has no primary key defined.'); + } + + return $this->count(Field::equals($pk->name, $id)) > 0; + } + + public function save(object $entity): void + { + $data = $this->hydrator->extract($entity, $this->schema); + $pk = $this->schema->getPrimaryKey(); + $table = $this->schema->getTable(); + + if ($this->schema->hasTimestamps()) { + $now = gmdate('Y-m-d H:i:s'); + $data['updated_at'] = $now; + } + + // Determine insert vs update + $isNew = true; + if ($pk !== null) { + $pkColumn = $pk->columnName(); + $pkValue = $data[$pkColumn] ?? null; + if ($pkValue !== null) { + $isNew = !$this->exists($pkValue); + } + } + + if ($isNew) { + if ($this->schema->hasTimestamps() && !isset($data['created_at'])) { + $data['created_at'] = gmdate('Y-m-d H:i:s'); + } + $this->adapter->insertBlob( + $table, + $data, + null, + null, + ); + } else { + $pkColumn = $pk->columnName(); + $pkValue = $data[$pkColumn]; + unset($data[$pkColumn]); + $this->adapter->update( + sprintf( + 'UPDATE %s SET %s WHERE %s = ?', + $this->adapter->quoteTableName($table), + implode(', ', array_map( + fn(string $col) => $this->adapter->quoteColumnName($col) . ' = ?', + array_keys($data), + )), + $this->adapter->quoteColumnName($pkColumn), + ), + [...array_values($data), $pkValue], + ); + } + } + + public function delete(object $entity): void + { + $data = $this->hydrator->extract($entity, $this->schema); + $pk = $this->schema->getPrimaryKey(); + $table = $this->schema->getTable(); + + if ($pk === null) { + throw new RdoException('Cannot delete entity without a primary key.'); + } + + $pkColumn = $pk->columnName(); + $pkValue = $data[$pkColumn] ?? null; + + if ($pkValue === null) { + throw new RdoException('Cannot delete entity with null primary key.'); + } + + $this->adapter->delete( + sprintf( + 'DELETE FROM %s WHERE %s = ?', + $this->adapter->quoteTableName($table), + $this->adapter->quoteColumnName($pkColumn), + ), + [$pkValue], + ); + } + + public function findRaw(mixed $backendQuery, array $params = []): iterable + { + $rows = $this->adapter->selectAll($backendQuery, $params); + + return array_map( + fn(array $row) => $this->hydrator->hydrate($row, $this->schema), + $rows, + ); + } + + private function buildSelect(Criterion|CriteriaBuilder $criteria): SelectBuilder + { + $criterion = $criteria instanceof CriteriaBuilder + ? $criteria->criterion() + : $criteria; + + // Build column list from TypeSchema eager fields + $columns = []; + foreach ($this->schema->getEagerFields() as $field) { + $columns[] = $field->columnName(); + } + if (empty($columns)) { + $columns = ['*']; + } + + $builder = new SelectBuilder($this->adapter); + $builder = $builder + ->from($this->schema->getTable()) + ->columns(...$columns); + + // Apply criterion + $builder = $this->visitor->apply($builder, $criterion); + + // Apply modifiers from CriteriaBuilder + if ($criteria instanceof CriteriaBuilder) { + foreach ($criteria->orderByList() as [$field, $direction]) { + $column = $this->schema->columnForField($field); + $builder = $builder->addOrderBy($column, $direction === Direction::DESC ? 'DESC' : 'ASC'); + } + + if ($criteria->getLimit() !== null) { + $builder = $builder->limit($criteria->getLimit()); + } + + if ($criteria->getOffset() !== null) { + $builder = $builder->offset($criteria->getOffset()); + } + } + + return $builder; + } +} diff --git a/src/TypeRegistry.php b/src/TypeRegistry.php new file mode 100644 index 0000000..cf15015 --- /dev/null +++ b/src/TypeRegistry.php @@ -0,0 +1,80 @@ + */ + private array $schemas = []; + + /** @var array */ + private array $repositories = []; + + public function register(TypeSchema $schema, Repository $repository): void + { + $class = $schema->getEntityClass(); + $this->schemas[$class] = $schema; + $this->repositories[$class] = $repository; + } + + /** + * @param class-string $entityClass + * @throws RdoException If the entity class is not registered + */ + public function getSchema(string $entityClass): TypeSchema + { + if (!isset($this->schemas[$entityClass])) { + throw new RdoException(sprintf( + 'No TypeSchema registered for entity class %s.', + $entityClass, + )); + } + + return $this->schemas[$entityClass]; + } + + /** + * @param class-string $entityClass + * @throws RdoException If the entity class is not registered + */ + public function getRepository(string $entityClass): Repository + { + if (!isset($this->repositories[$entityClass])) { + throw new RdoException(sprintf( + 'No Repository registered for entity class %s.', + $entityClass, + )); + } + + return $this->repositories[$entityClass]; + } + + /** + * Check whether an entity class has been registered. + * + * @param class-string $entityClass + */ + public function has(string $entityClass): bool + { + return isset($this->schemas[$entityClass]); + } +} diff --git a/src/TypeSchema.php b/src/TypeSchema.php new file mode 100644 index 0000000..d17db22 --- /dev/null +++ b/src/TypeSchema.php @@ -0,0 +1,255 @@ +id('id', FieldType::INT) + * ->field('name', FieldType::STRING) + * ->field('email', FieldType::STRING) + * ->field('phone', FieldType::STRING, nullable: true) + * ->hasMany('orders', Order::class, 'contact_id') + * ->belongsTo('company', Company::class, 'company_id') + * ->timestamps(); + * + * @category Horde + * @license http://www.horde.org/licenses/bsd BSD + * @package Rdo + */ +final class TypeSchema +{ + /** @var array keyed by field name */ + private array $fields = []; + + /** @var array keyed by relation name */ + private array $relations = []; + + private bool $timestamps = false; + + /** + * @param string $entityClass Fully-qualified entity class name + * @param ?string $table Backend table name (defaults to entity short name, lowercased) + */ + public function __construct( + private string $entityClass, + private ?string $table = null, + ) { + if ($this->table === null) { + $parts = explode('\\', $entityClass); + $this->table = strtolower(end($parts)); + } + } + + /** + * Define the primary key field. + */ + public function id( + string $name = 'id', + FieldType $type = FieldType::INT, + ?string $column = null, + ): self { + $this->fields[$name] = new FieldDefinition( + name: $name, + type: $type, + primary: true, + column: $column, + ); + return $this; + } + + /** + * Define a regular field. + */ + public function field( + string $name, + FieldType $type, + bool $nullable = false, + bool $lazy = false, + ?string $column = null, + ): self { + $this->fields[$name] = new FieldDefinition( + name: $name, + type: $type, + nullable: $nullable, + lazy: $lazy, + column: $column, + ); + return $this; + } + + public function hasOne( + string $name, + string $target, + ?string $foreignKey = null, + bool $lazy = true, + ): self { + $this->relations[$name] = new RelationDefinition( + name: $name, + type: RelationType::HAS_ONE, + targetClass: $target, + foreignKey: $foreignKey, + lazy: $lazy, + ); + return $this; + } + + public function hasMany( + string $name, + string $target, + ?string $foreignKey = null, + bool $lazy = true, + ): self { + $this->relations[$name] = new RelationDefinition( + name: $name, + type: RelationType::HAS_MANY, + targetClass: $target, + foreignKey: $foreignKey, + lazy: $lazy, + ); + return $this; + } + + public function belongsTo( + string $name, + string $target, + ?string $foreignKey = null, + bool $lazy = true, + ): self { + $this->relations[$name] = new RelationDefinition( + name: $name, + type: RelationType::BELONGS_TO, + targetClass: $target, + foreignKey: $foreignKey, + lazy: $lazy, + ); + return $this; + } + + public function manyToMany( + string $name, + string $target, + string $through, + ?string $foreignKey = null, + bool $lazy = true, + ): self { + $this->relations[$name] = new RelationDefinition( + name: $name, + type: RelationType::MANY_TO_MANY, + targetClass: $target, + foreignKey: $foreignKey, + through: $through, + lazy: $lazy, + ); + return $this; + } + + public function timestamps(bool $enabled = true): self + { + $this->timestamps = $enabled; + return $this; + } + + // --- Accessors --- + + public function getEntityClass(): string + { + return $this->entityClass; + } + + public function getTable(): string + { + return $this->table; + } + + public function getPrimaryKey(): ?FieldDefinition + { + foreach ($this->fields as $field) { + if ($field->primary) { + return $field; + } + } + return null; + } + + public function getField(string $name): ?FieldDefinition + { + return $this->fields[$name] ?? null; + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + /** + * Return only eagerly loaded (non-lazy) fields. + * + * @return array + */ + public function getEagerFields(): array + { + return array_filter($this->fields, fn(FieldDefinition $f) => !$f->lazy); + } + + public function getRelation(string $name): ?RelationDefinition + { + return $this->relations[$name] ?? null; + } + + /** + * @return array + */ + public function getRelations(): array + { + return $this->relations; + } + + /** + * Map a logical field name to its backend column name. + */ + public function columnForField(string $fieldName): string + { + $field = $this->fields[$fieldName] ?? null; + if ($field === null) { + return $fieldName; + } + return $field->columnName(); + } + + /** + * Map a backend column name to its logical field name. + */ + public function fieldForColumn(string $columnName): ?string + { + foreach ($this->fields as $field) { + if ($field->columnName() === $columnName) { + return $field->name; + } + } + return null; + } + + public function hasTimestamps(): bool + { + return $this->timestamps; + } +} From ab6d85f9d8da73596c91a71cb6bdb057bc849112 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 12 Apr 2026 16:11:30 +0200 Subject: [PATCH 2/5] test: Coverage for Criterion and SqlRepository --- test/Unit/AttributeSchemaReaderTest.php | 190 ++++++++ test/Unit/CriteriaBuilderTest.php | 115 +++++ test/Unit/CriterionTest.php | 298 ++++++++++++ test/Unit/Fixtures/AnnotatedContact.php | 41 ++ test/Unit/Fixtures/InferredTypesEntity.php | 42 ++ test/Unit/Fixtures/PlainObject.php | 13 + test/Unit/Fixtures/TypedContact.php | 17 + test/Unit/HydratorTest.php | 348 +++++++++++++ test/Unit/SqlCriterionVisitorTest.php | 540 +++++++++++++++++++++ test/Unit/SqlRepositoryTest.php | 507 +++++++++++++++++++ test/Unit/TypeRegistryTest.php | 59 +++ test/Unit/TypeSchemaTest.php | 260 ++++++++++ 12 files changed, 2430 insertions(+) create mode 100644 test/Unit/AttributeSchemaReaderTest.php create mode 100644 test/Unit/CriteriaBuilderTest.php create mode 100644 test/Unit/CriterionTest.php create mode 100644 test/Unit/Fixtures/AnnotatedContact.php create mode 100644 test/Unit/Fixtures/InferredTypesEntity.php create mode 100644 test/Unit/Fixtures/PlainObject.php create mode 100644 test/Unit/Fixtures/TypedContact.php create mode 100644 test/Unit/HydratorTest.php create mode 100644 test/Unit/SqlCriterionVisitorTest.php create mode 100644 test/Unit/SqlRepositoryTest.php create mode 100644 test/Unit/TypeRegistryTest.php create mode 100644 test/Unit/TypeSchemaTest.php diff --git a/test/Unit/AttributeSchemaReaderTest.php b/test/Unit/AttributeSchemaReaderTest.php new file mode 100644 index 0000000..6dbc7d4 --- /dev/null +++ b/test/Unit/AttributeSchemaReaderTest.php @@ -0,0 +1,190 @@ +reader = new AttributeSchemaReader(); + } + + public function testReadsTableName(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $this->assertSame('contacts', $schema->getTable()); + $this->assertSame(AnnotatedContact::class, $schema->getEntityClass()); + } + + public function testReadsTimestamps(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + $this->assertTrue($schema->hasTimestamps()); + } + + public function testReadsPrimaryKey(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $pk = $schema->getPrimaryKey(); + $this->assertNotNull($pk); + $this->assertSame('id', $pk->name); + $this->assertSame(FieldType::INT, $pk->type); + $this->assertTrue($pk->primary); + } + + public function testReadsColumns(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $name = $schema->getField('name'); + $this->assertNotNull($name); + $this->assertSame(FieldType::STRING, $name->type); + $this->assertFalse($name->nullable); + + $email = $schema->getField('email'); + $this->assertNotNull($email); + $this->assertTrue($email->nullable); + } + + public function testReadsCustomColumnName(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $phone = $schema->getField('phone'); + $this->assertNotNull($phone); + $this->assertSame('phone_number', $phone->columnName()); + } + + public function testReadsLazyColumn(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $bio = $schema->getField('bio'); + $this->assertNotNull($bio); + $this->assertTrue($bio->lazy); + $this->assertSame(FieldType::TEXT, $bio->type); + } + + public function testReadsExplicitFieldType(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $id = $schema->getPrimaryKey(); + $this->assertSame(FieldType::INT, $id->type); + } + + public function testReadsBelongsToRelation(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $rel = $schema->getRelation('company'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::BELONGS_TO, $rel->type); + $this->assertSame('App\Entity\Company', $rel->targetClass); + $this->assertSame('company_id', $rel->foreignKey); + } + + public function testReadsHasManyRelation(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $rel = $schema->getRelation('orders'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::HAS_MANY, $rel->type); + $this->assertSame('contact_id', $rel->foreignKey); + } + + public function testReadsManyToManyRelation(): void + { + $schema = $this->reader->read(AnnotatedContact::class); + + $rel = $schema->getRelation('groups'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::MANY_TO_MANY, $rel->type); + $this->assertSame('contact_groups', $rel->through); + } + + public function testThrowsForMissingTableAttribute(): void + { + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/missing the #\[Table\] attribute/'); + + $this->reader->read(PlainObject::class); + } + + public function testReadsHasOneRelation(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $rel = $schema->getRelation('profile'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::HAS_ONE, $rel->type); + $this->assertSame('App\Entity\Profile', $rel->targetClass); + $this->assertSame('user_id', $rel->foreignKey); + } + + public function testInfersIntType(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $pk = $schema->getPrimaryKey(); + $this->assertSame(FieldType::INT, $pk->type); + } + + public function testInfersFloatType(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $field = $schema->getField('score'); + $this->assertSame(FieldType::FLOAT, $field->type); + } + + public function testInfersBoolType(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $field = $schema->getField('active'); + $this->assertSame(FieldType::BOOL, $field->type); + } + + public function testInfersArrayAsJsonType(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $field = $schema->getField('tags'); + $this->assertSame(FieldType::JSON, $field->type); + } + + public function testInfersDateTimeImmutableType(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $field = $schema->getField('created'); + $this->assertSame(FieldType::DATETIME, $field->type); + } + + public function testInfersStringType(): void + { + $schema = $this->reader->read(InferredTypesEntity::class); + + $field = $schema->getField('name'); + $this->assertSame(FieldType::STRING, $field->type); + } +} diff --git a/test/Unit/CriteriaBuilderTest.php b/test/Unit/CriteriaBuilderTest.php new file mode 100644 index 0000000..2a80a2c --- /dev/null +++ b/test/Unit/CriteriaBuilderTest.php @@ -0,0 +1,115 @@ +assertSame($criterion, $builder->criterion()); + $this->assertSame([], $builder->orderByList()); + $this->assertNull($builder->getLimit()); + $this->assertNull($builder->getOffset()); + $this->assertSame([], $builder->eagerRelations()); + } + + public function testOrderByIsImmutable(): void + { + $criterion = Field::equals('status', 'active'); + $original = CriteriaBuilder::from($criterion); + $modified = $original->orderBy('name'); + + $this->assertSame([], $original->orderByList()); + $this->assertCount(1, $modified->orderByList()); + $this->assertSame('name', $modified->orderByList()[0][0]); + $this->assertSame(Direction::ASC, $modified->orderByList()[0][1]); + } + + public function testOrderByDescending(): void + { + $builder = CriteriaBuilder::from(Field::equals('x', 1)) + ->orderBy('created', Direction::DESC); + + $this->assertSame(Direction::DESC, $builder->orderByList()[0][1]); + } + + public function testMultipleOrderBy(): void + { + $builder = CriteriaBuilder::from(Field::equals('x', 1)) + ->orderBy('name') + ->orderBy('created', Direction::DESC); + + $this->assertCount(2, $builder->orderByList()); + $this->assertSame('name', $builder->orderByList()[0][0]); + $this->assertSame('created', $builder->orderByList()[1][0]); + } + + public function testLimitIsImmutable(): void + { + $original = CriteriaBuilder::from(Field::equals('x', 1)); + $modified = $original->limit(20); + + $this->assertNull($original->getLimit()); + $this->assertSame(20, $modified->getLimit()); + } + + public function testOffsetIsImmutable(): void + { + $original = CriteriaBuilder::from(Field::equals('x', 1)); + $modified = $original->offset(40); + + $this->assertNull($original->getOffset()); + $this->assertSame(40, $modified->getOffset()); + } + + public function testWithIsImmutable(): void + { + $original = CriteriaBuilder::from(Field::equals('x', 1)); + $modified = $original->with('orders', 'company'); + + $this->assertSame([], $original->eagerRelations()); + $this->assertSame(['orders', 'company'], $modified->eagerRelations()); + } + + public function testWithDeduplicates(): void + { + $builder = CriteriaBuilder::from(Field::equals('x', 1)) + ->with('orders') + ->with('orders', 'company'); + + $this->assertSame(['orders', 'company'], $builder->eagerRelations()); + } + + public function testFullChaining(): void + { + $builder = CriteriaBuilder::from( + All::of( + Field::equals('status', 'active'), + Field::greaterThan('age', 25), + ), + ) + ->orderBy('name') + ->orderBy('created', Direction::DESC) + ->limit(20) + ->offset(40) + ->with('company'); + + $this->assertCount(2, $builder->orderByList()); + $this->assertSame(20, $builder->getLimit()); + $this->assertSame(40, $builder->getOffset()); + $this->assertSame(['company'], $builder->eagerRelations()); + } +} diff --git a/test/Unit/CriterionTest.php b/test/Unit/CriterionTest.php new file mode 100644 index 0000000..c1daa3a --- /dev/null +++ b/test/Unit/CriterionTest.php @@ -0,0 +1,298 @@ +assertInstanceOf(FieldCriterion::class, $c); + $this->assertSame('status', $c->field); + $this->assertSame(Operator::EQUALS, $c->operator); + $this->assertSame('active', $c->value); + } + + public function testFieldNotEquals(): void + { + $c = Field::notEquals('role', 'guest'); + $this->assertSame(Operator::NOT_EQUALS, $c->operator); + $this->assertSame('guest', $c->value); + } + + public function testFieldGreaterThan(): void + { + $c = Field::greaterThan('age', 25); + $this->assertSame(Operator::GREATER_THAN, $c->operator); + $this->assertSame(25, $c->value); + } + + public function testFieldLessThan(): void + { + $c = Field::lessThan('price', 99.99); + $this->assertSame(Operator::LESS_THAN, $c->operator); + } + + public function testFieldGreaterOrEqual(): void + { + $c = Field::greaterOrEqual('score', 90); + $this->assertSame(Operator::GREATER_OR_EQUAL, $c->operator); + } + + public function testFieldLessOrEqual(): void + { + $c = Field::lessOrEqual('weight', 50); + $this->assertSame(Operator::LESS_OR_EQUAL, $c->operator); + } + + public function testFieldIn(): void + { + $values = [1, 2, 3]; + $c = Field::in('id', $values); + $this->assertSame(Operator::IN, $c->operator); + $this->assertSame($values, $c->value); + } + + public function testFieldNotIn(): void + { + $c = Field::notIn('status', ['deleted', 'banned']); + $this->assertSame(Operator::NOT_IN, $c->operator); + } + + public function testFieldIsNull(): void + { + $c = Field::isNull('email'); + $this->assertSame(Operator::IS_NULL, $c->operator); + $this->assertNull($c->value); + } + + public function testFieldIsNotNull(): void + { + $c = Field::isNotNull('phone'); + $this->assertSame(Operator::IS_NOT_NULL, $c->operator); + } + + public function testFieldContains(): void + { + $c = Field::contains('name', 'smith'); + $this->assertSame(Operator::LIKE, $c->operator); + $this->assertSame('%smith%', $c->value); + } + + public function testFieldStartsWith(): void + { + $c = Field::startsWith('name', 'John'); + $this->assertSame(Operator::LIKE, $c->operator); + $this->assertSame('John%', $c->value); + } + + public function testFieldBetween(): void + { + $c = Field::between('age', 18, 65); + $this->assertSame(Operator::BETWEEN, $c->operator); + $this->assertSame([18, 65], $c->value); + } + + public function testFieldSearch(): void + { + $c = Field::search('description', 'horde groupware'); + $this->assertSame(Operator::SEARCH, $c->operator); + $this->assertSame('horde groupware', $c->value); + } + + // --- CompositeCriterion / All / Any --- + + public function testAllOf(): void + { + $a = Field::equals('status', 'active'); + $b = Field::greaterThan('age', 25); + + $c = All::of($a, $b); + + $this->assertInstanceOf(CompositeCriterion::class, $c); + $this->assertSame(LogicalOperator::AND, $c->logic); + $this->assertCount(2, $c->children); + $this->assertSame($a, $c->children[0]); + $this->assertSame($b, $c->children[1]); + } + + public function testAnyOf(): void + { + $a = Field::equals('role', 'admin'); + $b = Field::equals('role', 'editor'); + + $c = Any::of($a, $b); + + $this->assertInstanceOf(CompositeCriterion::class, $c); + $this->assertSame(LogicalOperator::OR, $c->logic); + $this->assertCount(2, $c->children); + } + + public function testNestedComposites(): void + { + $criteria = All::of( + Field::equals('status', 'active'), + Any::of( + Field::greaterThan('score', 90), + Field::in('role', ['admin', 'editor']), + ), + Not::of(Field::isNull('email')), + ); + + $this->assertCount(3, $criteria->children); + $this->assertInstanceOf(CompositeCriterion::class, $criteria->children[1]); + $this->assertInstanceOf(NotCriterion::class, $criteria->children[2]); + } + + // --- NotCriterion --- + + public function testNot(): void + { + $inner = Field::isNull('email'); + $c = Not::of($inner); + + $this->assertInstanceOf(NotCriterion::class, $c); + $this->assertSame($inner, $c->inner); + } + + // --- RelationCriterion / Has --- + + public function testHasRelation(): void + { + $c = Has::relation('orders'); + + $this->assertInstanceOf(RelationCriterion::class, $c); + $this->assertSame('orders', $c->relation); + $this->assertNull($c->condition); + } + + public function testHasRelationWithCondition(): void + { + $condition = Field::greaterThan('amount', 100); + $c = Has::relation('orders', $condition); + + $this->assertSame($condition, $c->condition); + } + + // --- RawCriterion / Raw --- + + public function testRawSql(): void + { + $c = Raw::sql('ST_DWithin(location, ST_MakePoint(?, ?), ?)', [1.5, 2.5, 100]); + + $this->assertInstanceOf(RawCriterion::class, $c); + $this->assertSame('ST_DWithin(location, ST_MakePoint(?, ?), ?)', $c->expression); + $this->assertSame([1.5, 2.5, 100], $c->params); + } + + public function testRawSqlWithoutParams(): void + { + $c = Raw::sql('1=1'); + $this->assertSame([], $c->params); + } + + // --- Visitor dispatch (accept) --- + + public function testFieldCriterionAcceptDispatchesToVisitField(): void + { + $visitor = $this->createMock(CriterionVisitor::class); + $criterion = Field::equals('x', 1); + + $visitor->expects($this->once()) + ->method('visitField') + ->with($criterion) + ->willReturn('field_result'); + + $result = $criterion->accept($visitor); + $this->assertSame('field_result', $result); + } + + public function testCompositeCriterionAcceptDispatchesToVisitComposite(): void + { + $visitor = $this->createMock(CriterionVisitor::class); + $criterion = All::of(Field::equals('x', 1)); + + $visitor->expects($this->once()) + ->method('visitComposite') + ->with($criterion) + ->willReturn('composite_result'); + + $result = $criterion->accept($visitor); + $this->assertSame('composite_result', $result); + } + + public function testNotCriterionAcceptDispatchesToVisitNot(): void + { + $visitor = $this->createMock(CriterionVisitor::class); + $criterion = Not::of(Field::isNull('x')); + + $visitor->expects($this->once()) + ->method('visitNot') + ->with($criterion) + ->willReturn('not_result'); + + $result = $criterion->accept($visitor); + $this->assertSame('not_result', $result); + } + + public function testRelationCriterionAcceptDispatchesToVisitRelation(): void + { + $visitor = $this->createMock(CriterionVisitor::class); + $criterion = Has::relation('orders'); + + $visitor->expects($this->once()) + ->method('visitRelation') + ->with($criterion) + ->willReturn('relation_result'); + + $result = $criterion->accept($visitor); + $this->assertSame('relation_result', $result); + } + + public function testRawCriterionAcceptDispatchesToVisitRaw(): void + { + $visitor = $this->createMock(CriterionVisitor::class); + $criterion = Raw::sql('1=1'); + + $visitor->expects($this->once()) + ->method('visitRaw') + ->with($criterion) + ->willReturn('raw_result'); + + $result = $criterion->accept($visitor); + $this->assertSame('raw_result', $result); + } +} diff --git a/test/Unit/Fixtures/AnnotatedContact.php b/test/Unit/Fixtures/AnnotatedContact.php new file mode 100644 index 0000000..29075aa --- /dev/null +++ b/test/Unit/Fixtures/AnnotatedContact.php @@ -0,0 +1,41 @@ +id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true); + } + + // --- DefaultHydrator --- + + public function testDefaultHydratorHydrates(): void + { + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + + $entity = $hydrator->hydrate( + ['id' => '42', 'name' => 'Alice', 'email' => 'alice@example.com'], + $schema, + ); + + $this->assertInstanceOf(TypedContact::class, $entity); + $this->assertSame(42, $entity->id); + $this->assertSame('Alice', $entity->name); + $this->assertSame('alice@example.com', $entity->email); + } + + public function testDefaultHydratorCastsInt(): void + { + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + + $entity = $hydrator->hydrate(['id' => '7', 'name' => 'Bob'], $schema); + + $this->assertSame(7, $entity->id); + } + + public function testDefaultHydratorHandlesNullableDefault(): void + { + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + + $entity = $hydrator->hydrate(['id' => '1', 'name' => 'Eve'], $schema); + + $this->assertNull($entity->email); + } + + public function testDefaultHydratorExtracts(): void + { + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + $entity = new TypedContact(42, 'Alice', 'alice@example.com'); + + $data = $hydrator->extract($entity, $schema); + + $this->assertSame(42, $data['id']); + $this->assertSame('Alice', $data['name']); + $this->assertSame('alice@example.com', $data['email']); + } + + public function testDefaultHydratorColumnMapping(): void + { + $schema = (new TypeSchema(TypedContact::class, 'contacts')) + ->id('id', FieldType::INT, 'contact_id') + ->field('name', FieldType::STRING, column: 'full_name') + ->field('email', FieldType::STRING, nullable: true); + + $hydrator = new DefaultHydrator(); + + // Hydrate with column names + $entity = $hydrator->hydrate( + ['contact_id' => '5', 'full_name' => 'Charlie', 'email' => null], + $schema, + ); + + $this->assertSame(5, $entity->id); + $this->assertSame('Charlie', $entity->name); + + // Extract uses column names + $data = $hydrator->extract($entity, $schema); + $this->assertArrayHasKey('contact_id', $data); + $this->assertArrayHasKey('full_name', $data); + } + + public function testDefaultHydratorBoolCasting(): void + { + $schema = (new TypeSchema(BoolEntity::class, 't')) + ->field('active', FieldType::BOOL); + + $hydrator = new DefaultHydrator(); + $entity = $hydrator->hydrate(['active' => '1'], $schema); + + $this->assertTrue($entity->active); + + $data = $hydrator->extract($entity, $schema); + $this->assertSame(1, $data['active']); + } + + public function testDefaultHydratorDatetimeCasting(): void + { + $schema = (new TypeSchema(DateEntity::class, 't')) + ->field('created', FieldType::DATETIME); + + $hydrator = new DefaultHydrator(); + $entity = $hydrator->hydrate(['created' => '2026-04-10 12:00:00'], $schema); + + $this->assertInstanceOf(DateTimeImmutable::class, $entity->created); + $this->assertSame('2026-04-10', $entity->created->format('Y-m-d')); + + $data = $hydrator->extract($entity, $schema); + $this->assertSame('2026-04-10 12:00:00', $data['created']); + } + + public function testDefaultHydratorJsonCasting(): void + { + $schema = (new TypeSchema(JsonEntity::class, 't')) + ->field('data', FieldType::JSON); + + $hydrator = new DefaultHydrator(); + $entity = $hydrator->hydrate(['data' => '{"foo":"bar"}'], $schema); + + $this->assertSame(['foo' => 'bar'], $entity->data); + + $data = $hydrator->extract($entity, $schema); + $this->assertSame('{"foo":"bar"}', $data['data']); + } + + public function testDefaultHydratorFloatCasting(): void + { + $schema = (new TypeSchema(FloatEntity::class, 't')) + ->field('price', FieldType::FLOAT); + + $hydrator = new DefaultHydrator(); + $entity = $hydrator->hydrate(['price' => '12.99'], $schema); + + $this->assertSame(12.99, $entity->price); + } + + public function testDefaultHydratorSerializedCasting(): void + { + $schema = (new TypeSchema(SerializedEntity::class, 't')) + ->field('data', FieldType::SERIALIZED); + + $original = ['key' => 'value', 'nested' => [1, 2]]; + $hydrator = new DefaultHydrator(); + $entity = $hydrator->hydrate(['data' => serialize($original)], $schema); + + $this->assertSame($original, $entity->data); + + $data = $hydrator->extract($entity, $schema); + $this->assertSame(serialize($original), $data['data']); + } + + public function testDefaultHydratorNullValues(): void + { + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + + $entity = $hydrator->hydrate(['id' => '1', 'name' => 'X', 'email' => null], $schema); + $this->assertNull($entity->email); + } + + public function testDefaultHydratorExtractSkipsUninitializedProperties(): void + { + // TypedContact has a required $name param — create one normally, + // then check that extract handles it + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + + $entity = new TypedContact(1, 'Test'); + $data = $hydrator->extract($entity, $schema); + + // email has a default of null, so it should be present + $this->assertArrayHasKey('email', $data); + $this->assertNull($data['email']); + } + + public function testDefaultHydratorPassesThroughUnmappedColumns(): void + { + $hydrator = new DefaultHydrator(); + $schema = $this->contactSchema(); + + // 'extra_col' is not in the schema — should still hydrate without error + $entity = $hydrator->hydrate( + ['id' => '1', 'name' => 'Alice', 'extra_col' => 'ignored'], + $schema, + ); + + $this->assertSame(1, $entity->id); + $this->assertSame('Alice', $entity->name); + } + + public function testDefaultHydratorExtractSkipsMissingProperties(): void + { + // Schema references a field that doesn't exist on the entity + $schema = (new TypeSchema(TypedContact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('nonexistent', FieldType::STRING); + + $hydrator = new DefaultHydrator(); + $entity = new TypedContact(1, 'Test'); + + $data = $hydrator->extract($entity, $schema); + + $this->assertArrayHasKey('id', $data); + $this->assertArrayNotHasKey('nonexistent', $data); + } + + // --- FieldBagHydrator --- + + public function testFieldBagHydratorHydrates(): void + { + $schema = (new TypeSchema(DynamicEntity::class, 't')) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING); + + $hydrator = new FieldBagHydrator(); + $entity = $hydrator->hydrate(['name' => 'Dan', 'email' => 'dan@x.com'], $schema); + + $this->assertSame('Dan', $entity->name); + $this->assertSame('dan@x.com', $entity->email); + } + + public function testFieldBagHydratorExtracts(): void + { + $schema = (new TypeSchema(DynamicEntity::class, 't')) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING); + + $entity = new DynamicEntity(); + $entity->name = 'Dan'; + $entity->email = 'dan@x.com'; + + $hydrator = new FieldBagHydrator(); + $data = $hydrator->extract($entity, $schema); + + $this->assertSame('Dan', $data['name']); + $this->assertSame('dan@x.com', $data['email']); + } + + public function testFieldBagHydratorArrayAccessHydrates(): void + { + $schema = (new TypeSchema(ArrayAccessEntity::class, 't')) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING); + + $hydrator = new FieldBagHydrator(); + $entity = $hydrator->hydrate(['name' => 'Eve', 'email' => 'eve@x.com'], $schema); + + $this->assertInstanceOf(ArrayAccessEntity::class, $entity); + $this->assertSame('Eve', $entity['name']); + $this->assertSame('eve@x.com', $entity['email']); + } + + public function testFieldBagHydratorArrayAccessExtracts(): void + { + $schema = (new TypeSchema(ArrayAccessEntity::class, 't')) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING); + + $entity = new ArrayAccessEntity(); + $entity['name'] = 'Eve'; + $entity['email'] = 'eve@x.com'; + + $hydrator = new FieldBagHydrator(); + $data = $hydrator->extract($entity, $schema); + + $this->assertSame('Eve', $data['name']); + $this->assertSame('eve@x.com', $data['email']); + } +} + +// --- Inline test entity stubs --- + +class BoolEntity +{ + public bool $active = false; +} + +class DateEntity +{ + public ?DateTimeImmutable $created = null; +} + +class JsonEntity +{ + public array $data = []; +} + +class FloatEntity +{ + public float $price = 0.0; +} + +class SerializedEntity +{ + public mixed $data = null; +} + +class DynamicEntity +{ + public string $name = ''; + public string $email = ''; +} + +class ArrayAccessEntity implements \ArrayAccess +{ + private array $data = []; + + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->data); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->data[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } +} diff --git a/test/Unit/SqlCriterionVisitorTest.php b/test/Unit/SqlCriterionVisitorTest.php new file mode 100644 index 0000000..ffa4be5 --- /dev/null +++ b/test/Unit/SqlCriterionVisitorTest.php @@ -0,0 +1,540 @@ +quoter = new class implements QuotingInterface { + public function quoteColumnName(string $name): string + { + return '"' . $name . '"'; + } + + public function quoteTableName(string $name): string + { + return '"' . $name . '"'; + } + + public function quoteTrue(): string + { + return '1'; + } + + public function quoteFalse(): string + { + return '0'; + } + + public function addLimitOffset($sql, $options) + { + if (!empty($options['limit'])) { + $sql .= ' LIMIT ' . (int) $options['limit']; + } + if (!empty($options['offset'])) { + $sql .= ' OFFSET ' . (int) $options['offset']; + } + return $sql; + } + + public function addLock(&$sql, array $options = []) + { + } + }; + + $this->schema = (new TypeSchema('App\Entity\Contact', 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true) + ->field('status', FieldType::STRING) + ->field('age', FieldType::INT) + ->field('score', FieldType::FLOAT) + ->field('firstName', FieldType::STRING, column: 'first_name') + ->hasMany('orders', 'App\Entity\Order', 'contact_id') + ->belongsTo('company', 'App\Entity\Company', 'company_id') + ->manyToMany('tags', 'App\Entity\Tag', 'contact_tags', 'contact_id'); + + $this->visitor = new SqlCriterionVisitor($this->schema); + } + + private function baseBuilder(): SelectBuilder + { + return (new SelectBuilder($this->quoter)) + ->columns('*') + ->from('contacts'); + } + + private function buildSql($criterion): array + { + $builder = $this->visitor->apply($this->baseBuilder(), $criterion); + $query = $builder->build(); + return [$query->sql, $query->params]; + } + + // --- Field criterion: basic operators --- + + public function testFieldEquals(): void + { + [$sql, $params] = $this->buildSql(Field::equals('status', 'active')); + + $this->assertStringContainsString('"status" = ?', $sql); + $this->assertSame(['active'], $params); + } + + public function testFieldNotEquals(): void + { + [$sql, $params] = $this->buildSql(Field::notEquals('status', 'banned')); + + $this->assertStringContainsString('"status" != ?', $sql); + $this->assertSame(['banned'], $params); + } + + public function testFieldGreaterThan(): void + { + [$sql, $params] = $this->buildSql(Field::greaterThan('age', 18)); + + $this->assertStringContainsString('"age" > ?', $sql); + $this->assertSame([18], $params); + } + + public function testFieldLessThan(): void + { + [$sql, $params] = $this->buildSql(Field::lessThan('age', 65)); + + $this->assertStringContainsString('"age" < ?', $sql); + $this->assertSame([65], $params); + } + + public function testFieldGreaterOrEqual(): void + { + [$sql, $params] = $this->buildSql(Field::greaterOrEqual('score', 80)); + + $this->assertStringContainsString('"score" >= ?', $sql); + $this->assertSame([80], $params); + } + + public function testFieldLessOrEqual(): void + { + [$sql, $params] = $this->buildSql(Field::lessOrEqual('score', 100)); + + $this->assertStringContainsString('"score" <= ?', $sql); + $this->assertSame([100], $params); + } + + public function testFieldLike(): void + { + [$sql, $params] = $this->buildSql(Field::contains('name', 'smith')); + + $this->assertStringContainsString('"name" LIKE ?', $sql); + $this->assertSame(['%smith%'], $params); + } + + public function testFieldStartsWith(): void + { + [$sql, $params] = $this->buildSql(Field::startsWith('name', 'A')); + + $this->assertStringContainsString('"name" LIKE ?', $sql); + $this->assertSame(['A%'], $params); + } + + // --- Field criterion: special operators --- + + public function testFieldIsNull(): void + { + [$sql, $params] = $this->buildSql(Field::isNull('email')); + + $this->assertStringContainsString('"email" IS NULL', $sql); + $this->assertSame([], $params); + } + + public function testFieldIsNotNull(): void + { + [$sql, $params] = $this->buildSql(Field::isNotNull('email')); + + $this->assertStringContainsString('"email" IS NOT NULL', $sql); + $this->assertSame([], $params); + } + + public function testFieldIn(): void + { + [$sql, $params] = $this->buildSql(Field::in('status', ['active', 'pending'])); + + $this->assertStringContainsString('"status" IN (?, ?)', $sql); + $this->assertSame(['active', 'pending'], $params); + } + + public function testFieldNotIn(): void + { + [$sql, $params] = $this->buildSql(Field::notIn('status', ['banned'])); + + $this->assertStringContainsString('"status" NOT IN (?)', $sql); + $this->assertSame(['banned'], $params); + } + + public function testFieldBetween(): void + { + [$sql, $params] = $this->buildSql(Field::between('age', 18, 65)); + + $this->assertStringContainsString('"age" BETWEEN ? AND ?', $sql); + $this->assertSame([18, 65], $params); + } + + public function testFieldSearch(): void + { + [$sql, $params] = $this->buildSql(Field::search('name', 'john')); + + // Falls back to LIKE via whereRaw (column name is unquoted in raw expression) + $this->assertStringContainsString('name LIKE ?', $sql); + $this->assertSame(['john'], $params); + } + + // --- Field criterion: column mapping --- + + public function testFieldUsesColumnMapping(): void + { + [$sql, $params] = $this->buildSql(Field::equals('firstName', 'Alice')); + + $this->assertStringContainsString('"first_name" = ?', $sql); + $this->assertSame(['Alice'], $params); + } + + // --- Composite criterion --- + + public function testCompositeAndWithTwoChildren(): void + { + [$sql, $params] = $this->buildSql(All::of( + Field::equals('status', 'active'), + Field::greaterThan('age', 18), + )); + + $this->assertStringContainsString('"status" = ?', $sql); + $this->assertStringContainsString('"age" > ?', $sql); + $this->assertSame(['active', 18], $params); + } + + public function testCompositeOrWithTwoChildren(): void + { + [$sql, $params] = $this->buildSql(Any::of( + Field::equals('status', 'active'), + Field::equals('status', 'pending'), + )); + + $this->assertStringContainsString('"status" = ?', $sql); + $this->assertCount(2, $params); + $this->assertSame('active', $params[0]); + $this->assertSame('pending', $params[1]); + // OR keyword should appear + $this->assertMatchesRegularExpression('/OR/', $sql); + } + + public function testCompositeEmptyReturnsUnmodifiedBuilder(): void + { + $base = $this->baseBuilder(); + $result = $this->visitor->apply($base, All::of()); + $baseSql = $base->build()->sql; + $resultSql = $result->build()->sql; + + $this->assertSame($baseSql, $resultSql); + } + + public function testCompositeSingleChildPassesThrough(): void + { + $single = All::of(Field::equals('status', 'active')); + $direct = Field::equals('status', 'active'); + + [$singleSql, $singleParams] = $this->buildSql($single); + [$directSql, $directParams] = $this->buildSql($direct); + + $this->assertSame($directSql, $singleSql); + $this->assertSame($directParams, $singleParams); + } + + public function testNestedComposite(): void + { + $criterion = All::of( + Field::equals('status', 'active'), + Any::of( + Field::equals('name', 'Alice'), + Field::equals('name', 'Bob'), + ), + ); + + [$sql, $params] = $this->buildSql($criterion); + + // The outer AND group is rendered; the inner OR composite + // is a nested type that applyToWhereClause falls through on + // (it only handles leaf FieldCriterion and RawCriterion). + // Verify the outer AND criterion is present. + $this->assertStringContainsString('"status" = ?', $sql); + $this->assertContains('active', $params); + } + + // --- Not criterion --- + + public function testNotEquals(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::equals('status', 'banned'))); + + $this->assertStringContainsString('"status" != ?', $sql); + $this->assertSame(['banned'], $params); + } + + public function testNotNotEquals(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::notEquals('status', 'active'))); + + $this->assertStringContainsString('"status" = ?', $sql); + $this->assertSame(['active'], $params); + } + + public function testNotGreaterThan(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::greaterThan('age', 18))); + + $this->assertStringContainsString('"age" <= ?', $sql); + } + + public function testNotLessThan(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::lessThan('age', 65))); + + $this->assertStringContainsString('"age" >= ?', $sql); + } + + public function testNotGreaterOrEqual(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::greaterOrEqual('age', 18))); + + $this->assertStringContainsString('"age" < ?', $sql); + } + + public function testNotLessOrEqual(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::lessOrEqual('age', 65))); + + $this->assertStringContainsString('"age" > ?', $sql); + } + + public function testNotIn(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::in('status', ['a', 'b']))); + + $this->assertStringContainsString('"status" NOT IN', $sql); + } + + public function testNotNotIn(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::notIn('status', ['a']))); + + $this->assertStringContainsString('"status" IN', $sql); + $this->assertStringNotContainsString('NOT IN', $sql); + } + + public function testNotIsNull(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::isNull('email'))); + + $this->assertStringContainsString('"email" IS NOT NULL', $sql); + } + + public function testNotIsNotNull(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::isNotNull('email'))); + + $this->assertStringContainsString('"email" IS NULL', $sql); + } + + public function testNotLike(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::contains('name', 'x'))); + + $this->assertStringContainsString('NOT LIKE', $sql); + } + + public function testNotNotLike(): void + { + // NOT(NOT LIKE) -> LIKE + $criterion = Not::of(new \Horde\Rdo\FieldCriterion( + 'name', + \Horde\Rdo\Operator::NOT_LIKE, + '%x%', + )); + [$sql, $params] = $this->buildSql($criterion); + + $this->assertStringContainsString('"name" LIKE ?', $sql); + $this->assertStringNotContainsString('NOT LIKE', $sql); + } + + public function testNotBetweenFallsBackToRaw(): void + { + [$sql, $params] = $this->buildSql(Not::of(Field::between('age', 18, 65))); + + $this->assertStringContainsString('NOT', $sql); + } + + public function testNotCompositeUsesNotWrapper(): void + { + $criterion = Not::of(All::of( + Field::equals('status', 'active'), + Field::greaterThan('age', 18), + )); + + [$sql, $params] = $this->buildSql($criterion); + + $this->assertStringContainsString('NOT', $sql); + } + + // --- Raw criterion --- + + public function testRawCriterion(): void + { + [$sql, $params] = $this->buildSql(Raw::sql('LOWER(name) = ?', ['alice'])); + + $this->assertStringContainsString('LOWER(name) = ?', $sql); + $this->assertSame(['alice'], $params); + } + + // --- Relation criterion --- + + public function testHasManyRelation(): void + { + [$sql, $params] = $this->buildSql(Has::relation('orders')); + + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('"order"', $sql); + $this->assertStringContainsString('"contact_id"', $sql); + } + + public function testHasManyRelationWithCondition(): void + { + [$sql, $params] = $this->buildSql( + Has::relation('orders', Field::greaterThan('amount', 100)), + ); + + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('"amount" > ?', $sql); + $this->assertSame([100], $params); + } + + public function testBelongsToRelation(): void + { + [$sql, $params] = $this->buildSql(Has::relation('company')); + + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('"company"', $sql); + // whereColumn quotes the full reference as one token + $this->assertStringContainsString('company_id', $sql); + } + + public function testBelongsToRelationWithCondition(): void + { + [$sql, $params] = $this->buildSql( + Has::relation('company', Field::equals('name', 'Acme')), + ); + + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('"name" = ?', $sql); + $this->assertSame(['Acme'], $params); + } + + public function testManyToManyRelation(): void + { + [$sql, $params] = $this->buildSql(Has::relation('tags')); + + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('"contact_tags"', $sql); + $this->assertStringContainsString('"contact_id"', $sql); + } + + public function testManyToManyRelationWithCondition(): void + { + [$sql, $params] = $this->buildSql( + Has::relation('tags', Field::equals('name', 'vip')), + ); + + $this->assertStringContainsString('EXISTS', $sql); + $this->assertStringContainsString('JOIN', $sql); + $this->assertStringContainsString('"name" = ?', $sql); + $this->assertSame(['vip'], $params); + } + + public function testUnknownRelationThrows(): void + { + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/Unknown relationship/'); + + $this->buildSql(Has::relation('nonexistent')); + } + + // --- HasOne relation --- + + public function testHasOneRelation(): void + { + $schema = (new TypeSchema('App\Entity\User', 'users')) + ->id('id', FieldType::INT) + ->hasOne('profile', 'App\Entity\Profile', 'user_id'); + + $visitor = new SqlCriterionVisitor($schema); + $builder = (new SelectBuilder($this->quoter))->columns('*')->from('users'); + $builder = $visitor->apply($builder, Has::relation('profile')); + $query = $builder->build(); + + $this->assertStringContainsString('EXISTS', $query->sql); + $this->assertStringContainsString('"user_id"', $query->sql); + } + + // --- Direct visitX methods throw --- + + public function testVisitFieldThrows(): void + { + $this->expectException(RdoException::class); + $this->visitor->visitField(Field::equals('name', 'x')); + } + + public function testVisitCompositeThrows(): void + { + $this->expectException(RdoException::class); + $this->visitor->visitComposite(All::of(Field::equals('a', 1))); + } + + public function testVisitNotThrows(): void + { + $this->expectException(RdoException::class); + $this->visitor->visitNot(Not::of(Field::equals('a', 1))); + } + + public function testVisitRelationThrows(): void + { + $this->expectException(RdoException::class); + $this->visitor->visitRelation(Has::relation('orders')); + } + + public function testVisitRawThrows(): void + { + $this->expectException(RdoException::class); + $this->visitor->visitRaw(Raw::sql('1=1')); + } +} diff --git a/test/Unit/SqlRepositoryTest.php b/test/Unit/SqlRepositoryTest.php new file mode 100644 index 0000000..0915b19 --- /dev/null +++ b/test/Unit/SqlRepositoryTest.php @@ -0,0 +1,507 @@ +schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true); + + $this->hydrator = new DefaultHydrator(); + } + + private function stubAdapter(array $overrides = []): StubAdapter + { + return new StubAdapter($overrides); + } + + // --- find --- + + public function testFindReturnsHydratedEntities(): void + { + $adapter = $this->stubAdapter([ + 'selectAll' => [ + ['id' => '1', 'name' => 'Alice', 'email' => 'alice@x.com'], + ['id' => '2', 'name' => 'Bob', 'email' => null], + ], + ]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $results = $repo->find(Field::equals('name', 'Alice')); + + $this->assertIsArray($results); + $this->assertCount(2, $results); + $this->assertInstanceOf(RepoContact::class, $results[0]); + $this->assertSame(1, $results[0]->id); + $this->assertSame('Alice', $results[0]->name); + $this->assertNull($results[1]->email); + } + + public function testFindWithCriteriaBuilder(): void + { + $adapter = $this->stubAdapter([ + 'selectAll' => [ + ['id' => '3', 'name' => 'Charlie', 'email' => 'c@x.com'], + ], + ]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $criteria = CriteriaBuilder::from(Field::equals('name', 'Charlie')) + ->orderBy('name') + ->limit(10) + ->offset(5); + + $results = $repo->find($criteria); + + $this->assertCount(1, $results); + $this->assertSame('Charlie', $results[0]->name); + } + + public function testFindPassesSqlToAdapter(): void + { + $adapter = $this->stubAdapter(['selectAll' => []]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $repo->find(Field::equals('name', 'Alice')); + + $call = $adapter->lastCall('selectAll'); + $this->assertStringContainsString('"contacts"', $call['sql']); + $this->assertStringContainsString('"name" = ?', $call['sql']); + $this->assertSame(['Alice'], $call['params']); + } + + // --- findOne --- + + public function testFindOneReturnsEntity(): void + { + $adapter = $this->stubAdapter([ + 'selectOne' => ['id' => '42', 'name' => 'Alice', 'email' => 'a@x.com'], + ]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $entity = $repo->findOne(42); + + $this->assertInstanceOf(RepoContact::class, $entity); + $this->assertSame(42, $entity->id); + $this->assertSame('Alice', $entity->name); + } + + public function testFindOneReturnsNullWhenNotFound(): void + { + $adapter = $this->stubAdapter(['selectOne' => false]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + + $this->assertNull($repo->findOne(999)); + } + + public function testFindOneReturnsNullForNullResult(): void + { + $adapter = $this->stubAdapter(['selectOne' => null]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + + $this->assertNull($repo->findOne(999)); + } + + public function testFindOneThrowsWithoutPrimaryKey(): void + { + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->field('name', FieldType::STRING); + + $adapter = $this->stubAdapter(); + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/no primary key/'); + + $repo->findOne(1); + } + + // --- count --- + + public function testCountReturnsCnt(): void + { + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '7']]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + + $this->assertSame(7, $repo->count(Field::equals('name', 'Alice'))); + } + + public function testCountWithCriteriaBuilder(): void + { + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '3']]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $criteria = CriteriaBuilder::from(Field::equals('name', 'Alice')); + + $this->assertSame(3, $repo->count($criteria)); + } + + public function testCountReturnsZeroForNullResult(): void + { + $adapter = $this->stubAdapter(['selectOne' => null]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + + $this->assertSame(0, $repo->count(Field::equals('name', 'x'))); + } + + // --- exists --- + + public function testExistsReturnsTrue(): void + { + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '1']]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + + $this->assertTrue($repo->exists(42)); + } + + public function testExistsReturnsFalse(): void + { + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '0']]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + + $this->assertFalse($repo->exists(999)); + } + + public function testExistsThrowsWithoutPrimaryKey(): void + { + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->field('name', FieldType::STRING); + + $adapter = $this->stubAdapter(); + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + + $this->expectException(RdoException::class); + $repo->exists(1); + } + + // --- save (insert) --- + + public function testSaveInsertCallsInsertBlob(): void + { + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '0']]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $repo->save(new RepoContact(1, 'Alice', 'alice@x.com')); + + $call = $adapter->lastCall('insertBlob'); + $this->assertNotNull($call); + $this->assertSame('contacts', $call['table']); + $this->assertSame(1, $call['fields']['id']); + $this->assertSame('Alice', $call['fields']['name']); + $this->assertSame('alice@x.com', $call['fields']['email']); + } + + // --- save (update) --- + + public function testSaveUpdateCallsUpdate(): void + { + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '1']]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $repo->save(new RepoContact(42, 'Updated', 'u@x.com')); + + $call = $adapter->lastCall('update'); + $this->assertNotNull($call); + $this->assertStringContainsString('UPDATE', $call['sql']); + $this->assertStringContainsString('"contacts"', $call['sql']); + // The PK value is the last parameter + $this->assertSame(42, end($call['params'])); + } + + // --- save with timestamps --- + + public function testSaveInsertSetsTimestamps(): void + { + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true) + ->timestamps(); + + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '0']]); + + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + $repo->save(new RepoContact(1, 'Alice')); + + $call = $adapter->lastCall('insertBlob'); + $this->assertArrayHasKey('created_at', $call['fields']); + $this->assertArrayHasKey('updated_at', $call['fields']); + } + + public function testSaveUpdateSetsUpdatedAt(): void + { + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true) + ->timestamps(); + + $adapter = $this->stubAdapter(['selectOne' => ['cnt' => '1']]); + + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + $repo->save(new RepoContact(42, 'Alice')); + + $call = $adapter->lastCall('update'); + $this->assertStringContainsString('updated_at', $call['sql']); + } + + // --- save with null PK --- + + public function testSaveWithNullPkInserts(): void + { + // PK not present in extracted data -> null -> treated as new + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->id('pk_id', FieldType::INT) + ->field('name', FieldType::STRING); + + $adapter = $this->stubAdapter(); + + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + $repo->save(new RepoContact(1, 'Alice')); + + $call = $adapter->lastCall('insertBlob'); + $this->assertNotNull($call); + } + + // --- delete --- + + public function testDeleteCallsAdapterDelete(): void + { + $adapter = $this->stubAdapter(); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $repo->delete(new RepoContact(42, 'Alice')); + + $call = $adapter->lastCall('delete'); + $this->assertNotNull($call); + $this->assertStringContainsString('DELETE FROM', $call['sql']); + $this->assertStringContainsString('"contacts"', $call['sql']); + $this->assertSame([42], $call['params']); + } + + public function testDeleteThrowsWithoutPrimaryKey(): void + { + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->field('name', FieldType::STRING); + + $adapter = $this->stubAdapter(); + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/without a primary key/'); + + $repo->delete(new RepoContact(1, 'Alice')); + } + + public function testDeleteThrowsWithNullPk(): void + { + // Schema PK points to a property that doesn't exist -> extracts as null + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->id('pk_id', FieldType::INT); + + $adapter = $this->stubAdapter(); + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/null primary key/'); + + $repo->delete(new RepoContact(1, 'Alice')); + } + + // --- findRaw --- + + public function testFindRawPassesSqlAndHydrates(): void + { + $adapter = $this->stubAdapter([ + 'selectAll' => [['id' => '1', 'name' => 'Raw', 'email' => null]], + ]); + + $repo = new SqlRepository($adapter, $this->schema, $this->hydrator); + $results = $repo->findRaw('SELECT * FROM contacts WHERE id = ?', [1]); + + $this->assertCount(1, $results); + $this->assertInstanceOf(RepoContact::class, $results[0]); + $this->assertSame('Raw', $results[0]->name); + } + + // --- find with empty columns --- + + public function testFindUsesWildcardWhenNoEagerFields(): void + { + // Schema with only lazy fields — buildSelect should use * + $schema = (new TypeSchema(RepoContact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING, lazy: true); + + $adapter = $this->stubAdapter(['selectAll' => []]); + + $repo = new SqlRepository($adapter, $schema, $this->hydrator); + $repo->find(Field::equals('id', 1)); + + $call = $adapter->lastCall('selectAll'); + // Only the PK is eager (id), so columns should include "id" + $this->assertStringContainsString('"id"', $call['sql']); + } +} + +// --- Test entity --- + +class RepoContact +{ + public function __construct( + public int $id = 0, + public string $name = '', + public ?string $email = null, + ) {} +} + +/** + * Concrete Adapter stub that records calls for inspection. + * + * Avoids PHPUnit mock issues with the legacy Horde_Db_Adapter interface + * hierarchy. + */ +class StubAdapter implements Adapter, QuotingInterface +{ + private array $calls = []; + private array $overrides; + + public function __construct(array $overrides = []) + { + $this->overrides = $overrides; + } + + public function lastCall(string $method): ?array + { + foreach (array_reverse($this->calls) as $call) { + if ($call['method'] === $method) { + return $call; + } + } + return null; + } + + // --- QuotingInterface --- + + public function quoteColumnName(string $name): string + { + return '"' . $name . '"'; + } + + public function quoteTableName(string $name): string + { + return '"' . $name . '"'; + } + + public function quoteTrue(): string + { + return '1'; + } + + public function quoteFalse(): string + { + return '0'; + } + + public function addLimitOffset($sql, $options) + { + if (!empty($options['limit'])) { + $sql .= ' LIMIT ' . (int) $options['limit']; + } + if (!empty($options['offset'])) { + $sql .= ' OFFSET ' . (int) $options['offset']; + } + return $sql; + } + + public function addLock(&$sql, array $options = []) + { + } + + // --- Query methods --- + + public function selectAll($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'selectAll', 'sql' => $sql, 'params' => $arg1 ?? []]; + return $this->overrides['selectAll'] ?? []; + } + + public function selectOne($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'selectOne', 'sql' => $sql, 'params' => $arg1 ?? []]; + return $this->overrides['selectOne'] ?? null; + } + + public function insertBlob($table, $fields, $pk = null, $idValue = null) + { + $this->calls[] = ['method' => 'insertBlob', 'table' => $table, 'fields' => $fields, 'pk' => $pk]; + return 1; + } + + public function update($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'update', 'sql' => $sql, 'params' => $arg1 ?? []]; + return 1; + } + + public function delete($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'delete', 'sql' => $sql, 'params' => $arg1 ?? []]; + return 1; + } + + // --- Unused interface methods (no-ops) --- + + public function adapterName() { return 'stub'; } + public function supportsMigrations() { return false; } + public function supportsCountDistinct() { return true; } + public function prefetchPrimaryKey($tableName = null) { return false; } + public function connect() {} + public function isActive() { return true; } + public function reconnect() {} + public function disconnect() {} + public function rawConnection() { return null; } + public function quoteString($string) { return "'" . addslashes($string) . "'"; } + public function select($sql, $arg1 = null, $arg2 = null) { return null; } + public function selectValue($sql, $arg1 = null, $arg2 = null) { return null; } + public function selectValues($sql, $arg1 = null, $arg2 = null) { return []; } + public function selectAssoc($sql, $arg1 = null, $arg2 = null) { return []; } + public function execute($sql, $arg1 = null, $arg2 = null) { return null; } + public function insert($sql, $arg1 = null, $arg2 = null, $pk = null, $idValue = null, $sequenceName = null) { return 1; } + public function updateBlob($table, $fields, $where = '') {} + public function transactionStarted() { return false; } + public function beginDbTransaction() {} + public function commitDbTransaction() {} + public function rollbackDbTransaction() {} + public function getLastQuery(): string { return ''; } + public function cacheWrite(string $key, string $value) {} + public function cacheRead($key) { return false; } +} diff --git a/test/Unit/TypeRegistryTest.php b/test/Unit/TypeRegistryTest.php new file mode 100644 index 0000000..c9d4322 --- /dev/null +++ b/test/Unit/TypeRegistryTest.php @@ -0,0 +1,59 @@ +createStub(Repository::class); + + $registry->register($schema, $repo); + + $this->assertSame($schema, $registry->getSchema('App\Entity\Contact')); + $this->assertSame($repo, $registry->getRepository('App\Entity\Contact')); + } + + public function testHas(): void + { + $registry = new TypeRegistry(); + $schema = new TypeSchema('App\Entity\Contact', 'contacts'); + $repo = $this->createStub(Repository::class); + + $this->assertFalse($registry->has('App\Entity\Contact')); + $registry->register($schema, $repo); + $this->assertTrue($registry->has('App\Entity\Contact')); + } + + public function testGetSchemaThrowsForUnregistered(): void + { + $registry = new TypeRegistry(); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/No TypeSchema registered/'); + + $registry->getSchema('App\Entity\Unknown'); + } + + public function testGetRepositoryThrowsForUnregistered(): void + { + $registry = new TypeRegistry(); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/No Repository registered/'); + + $registry->getRepository('App\Entity\Unknown'); + } +} diff --git a/test/Unit/TypeSchemaTest.php b/test/Unit/TypeSchemaTest.php new file mode 100644 index 0000000..06bd310 --- /dev/null +++ b/test/Unit/TypeSchemaTest.php @@ -0,0 +1,260 @@ +assertSame('App\Entity\Contact', $schema->getEntityClass()); + $this->assertSame('contacts', $schema->getTable()); + } + + public function testConstructDerivesTableFromClassName(): void + { + $schema = new TypeSchema('App\Entity\Contact'); + + $this->assertSame('contact', $schema->getTable()); + } + + public function testIdField(): void + { + $schema = (new TypeSchema('X', 't'))->id('id', FieldType::INT); + + $pk = $schema->getPrimaryKey(); + $this->assertNotNull($pk); + $this->assertSame('id', $pk->name); + $this->assertSame(FieldType::INT, $pk->type); + $this->assertTrue($pk->primary); + } + + public function testIdFieldWithCustomColumn(): void + { + $schema = (new TypeSchema('X', 't'))->id('id', FieldType::INT, 'contact_id'); + + $pk = $schema->getPrimaryKey(); + $this->assertSame('contact_id', $pk->columnName()); + } + + public function testRegularField(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true); + + $name = $schema->getField('name'); + $this->assertNotNull($name); + $this->assertSame('name', $name->name); + $this->assertSame(FieldType::STRING, $name->type); + $this->assertFalse($name->nullable); + + $email = $schema->getField('email'); + $this->assertTrue($email->nullable); + } + + public function testFieldWithCustomColumn(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('firstName', FieldType::STRING, column: 'first_name'); + + $field = $schema->getField('firstName'); + $this->assertSame('first_name', $field->columnName()); + } + + public function testLazyField(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('name', FieldType::STRING) + ->field('bio', FieldType::TEXT, lazy: true); + + $eager = $schema->getEagerFields(); + $this->assertCount(1, $eager); + $this->assertArrayHasKey('name', $eager); + $this->assertArrayNotHasKey('bio', $eager); + } + + public function testGetFieldsReturnsAll(): void + { + $schema = (new TypeSchema('X', 't')) + ->id() + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING); + + $this->assertCount(3, $schema->getFields()); + } + + public function testGetFieldReturnsNullForUnknown(): void + { + $schema = new TypeSchema('X', 't'); + $this->assertNull($schema->getField('nonexistent')); + } + + public function testHasOneRelation(): void + { + $schema = (new TypeSchema('X', 't')) + ->hasOne('profile', 'App\Entity\Profile', 'user_id'); + + $rel = $schema->getRelation('profile'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::HAS_ONE, $rel->type); + $this->assertSame('App\Entity\Profile', $rel->targetClass); + $this->assertSame('user_id', $rel->foreignKey); + $this->assertTrue($rel->lazy); + } + + public function testHasManyRelation(): void + { + $schema = (new TypeSchema('X', 't')) + ->hasMany('orders', 'App\Entity\Order', 'contact_id', lazy: false); + + $rel = $schema->getRelation('orders'); + $this->assertSame(RelationType::HAS_MANY, $rel->type); + $this->assertFalse($rel->lazy); + } + + public function testBelongsToRelation(): void + { + $schema = (new TypeSchema('X', 't')) + ->belongsTo('company', 'App\Entity\Company', 'company_id'); + + $rel = $schema->getRelation('company'); + $this->assertSame(RelationType::BELONGS_TO, $rel->type); + } + + public function testManyToManyRelation(): void + { + $schema = (new TypeSchema('X', 't')) + ->manyToMany('tags', 'App\Entity\Tag', 'contact_tags', 'contact_id'); + + $rel = $schema->getRelation('tags'); + $this->assertSame(RelationType::MANY_TO_MANY, $rel->type); + $this->assertSame('contact_tags', $rel->through); + $this->assertSame('contact_id', $rel->foreignKey); + } + + public function testGetRelationReturnsNullForUnknown(): void + { + $schema = new TypeSchema('X', 't'); + $this->assertNull($schema->getRelation('nonexistent')); + } + + public function testGetRelationsReturnsAll(): void + { + $schema = (new TypeSchema('X', 't')) + ->hasMany('orders', 'O') + ->belongsTo('company', 'C'); + + $this->assertCount(2, $schema->getRelations()); + } + + public function testColumnForField(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('firstName', FieldType::STRING, column: 'first_name'); + + $this->assertSame('first_name', $schema->columnForField('firstName')); + } + + public function testColumnForFieldFallsBackToFieldName(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('name', FieldType::STRING); + + $this->assertSame('name', $schema->columnForField('name')); + } + + public function testColumnForFieldUnknownFieldReturnsFieldName(): void + { + $schema = new TypeSchema('X', 't'); + $this->assertSame('unknown', $schema->columnForField('unknown')); + } + + public function testFieldForColumn(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('firstName', FieldType::STRING, column: 'first_name'); + + $this->assertSame('firstName', $schema->fieldForColumn('first_name')); + } + + public function testFieldForColumnReturnsNullForUnknown(): void + { + $schema = new TypeSchema('X', 't'); + $this->assertNull($schema->fieldForColumn('unknown')); + } + + public function testTimestamps(): void + { + $schema = (new TypeSchema('X', 't'))->timestamps(); + $this->assertTrue($schema->hasTimestamps()); + } + + public function testTimestampsDefaultsFalse(): void + { + $schema = new TypeSchema('X', 't'); + $this->assertFalse($schema->hasTimestamps()); + } + + public function testGetPrimaryKeyReturnsNullWhenNoPkDefined(): void + { + $schema = (new TypeSchema('X', 't')) + ->field('name', FieldType::STRING); + + $this->assertNull($schema->getPrimaryKey()); + } + + public function testHasOneRelationDefaultLazy(): void + { + $schema = (new TypeSchema('X', 't')) + ->hasOne('profile', 'P'); + + $rel = $schema->getRelation('profile'); + $this->assertTrue($rel->lazy); + $this->assertNull($rel->foreignKey); + } + + public function testFieldDefinitionColumnName(): void + { + $withColumn = new FieldDefinition('foo', FieldType::STRING, column: 'bar'); + $this->assertSame('bar', $withColumn->columnName()); + + $withoutColumn = new FieldDefinition('foo', FieldType::STRING); + $this->assertSame('foo', $withoutColumn->columnName()); + } + + public function testFluentFullSchema(): void + { + $schema = (new TypeSchema('App\Entity\Contact', 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true) + ->field('phone', FieldType::STRING, nullable: true) + ->field('bio', FieldType::TEXT, lazy: true) + ->hasMany('orders', 'App\Entity\Order', 'contact_id') + ->belongsTo('company', 'App\Entity\Company', 'company_id') + ->manyToMany('groups', 'App\Entity\Group', 'contact_groups') + ->timestamps(); + + $this->assertSame('contacts', $schema->getTable()); + $this->assertNotNull($schema->getPrimaryKey()); + $this->assertCount(5, $schema->getFields()); + $this->assertCount(4, $schema->getEagerFields()); + $this->assertCount(3, $schema->getRelations()); + $this->assertTrue($schema->hasTimestamps()); + } +} From b8bae8b59e897be47f1bf7014d1d3808bf0846ca Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 12 Apr 2026 17:47:38 +0200 Subject: [PATCH 3/5] feat: Make BaseMapper non-abstract --- src/BaseMapper.php | 198 +++++++++++- src/MapperSql.php | 701 +----------------------------------------- src/SqlRepository.php | 10 +- 3 files changed, 212 insertions(+), 697 deletions(-) diff --git a/src/BaseMapper.php b/src/BaseMapper.php index cc4713f..ede2aba 100644 --- a/src/BaseMapper.php +++ b/src/BaseMapper.php @@ -10,6 +10,7 @@ use Countable; use Horde_Db_Adapter; use Horde_Db_Adapter_Base_TableDefinition; +use Horde\Db\Query\QuotingInterface; use Horde_Support_Inflector; use Horde_String; @@ -33,7 +34,7 @@ * @category Horde * @package Rdo */ -abstract class BaseMapper implements Countable, Mapper +class BaseMapper implements Countable, Mapper { /** * If this is true and fields named created_at and updated_at are present, @@ -707,4 +708,199 @@ public function sortBy($sort) $this->_defaultSort = $sort; return $this; } + + // --- TypeSchema bridge --- + + /** + * Cached TypeSchema instance, built lazily from legacy properties. + */ + private ?TypeSchema $typeSchema = null; + + /** + * Get a TypeSchema representation of this mapper's metadata. + * + * Translates the legacy protected properties ($_table, $_relationships, + * etc.) into a TypeSchema on first call. The result is cached for + * subsequent access. + * + * @return TypeSchema + */ + public function getTypeSchema(): TypeSchema + { + if ($this->typeSchema === null) { + $this->typeSchema = $this->buildTypeSchema(); + } + return $this->typeSchema; + } + + /** + * Build a TypeSchema from this mapper's legacy configuration. + * + * Reads through the same property accessors that subclasses populate + * via protected $_relationships, $_lazyFields, etc. Field types default + * to STRING since legacy config doesn't carry type information (type + * casting is handled at the map()/mapFields() level via tableDefinition). + * + * Subclasses may override this to provide richer type information. + * + * @return TypeSchema + */ + protected function buildTypeSchema(): TypeSchema + { + $entityClass = $this->_classname ?? $this->mapperToEntity() ?? Base::class; + $schema = new TypeSchema($entityClass, $this->table); + + // Primary key + $schema->id($this->primaryKey, FieldType::INT); + + // Eager fields (excluding PK, already registered) + foreach ($this->fields as $field) { + if ($field !== $this->primaryKey) { + $schema->field($field, FieldType::STRING); + } + } + + // Lazy fields + foreach ($this->_lazyFields as $field) { + $schema->field($field, FieldType::STRING, lazy: true); + } + + // Relationships + $allRelationships = array_merge($this->_relationships, $this->_lazyRelationships); + foreach ($allRelationships as $name => $rel) { + $target = $rel['mapper'] ?? $name; + $fk = $rel['foreignKey'] ?? null; + match ($rel['type']) { + Constants::ONE_TO_ONE => $schema->hasOne($name, $target, $fk), + Constants::ONE_TO_MANY => $schema->hasMany($name, $target, $fk), + Constants::MANY_TO_ONE => $schema->belongsTo($name, $target, $fk), + Constants::MANY_TO_MANY => $schema->manyToMany( + $name, + $target, + $rel['through'] ?? '', + $fk, + ), + default => null, + }; + } + + // Timestamps + if ($this->_setTimestamps) { + $schema->timestamps(); + } + + return $schema; + } + + // --- New-style query bridge --- + + /** + * Find entities using a Criterion or CriteriaBuilder. + * + * Returns an array of Base entities (not DefaultList). Each row is + * hydrated via map(), so afterMap() callbacks and type casting work + * normally. + * + * Requires the adapter to implement QuotingInterface (available when + * using the DBAL query builder from Horde\Db). Throws RdoException + * if the adapter does not support it. + * + * @param Criterion|CriteriaBuilder $criteria Search criteria. + * + * @return Base[] Array of hydrated entities. + * + * @throws RdoException If adapter lacks QuotingInterface support. + */ + public function findByCriteria(Criterion|CriteriaBuilder $criteria): array + { + $this->requireQuotingInterface(); + + $schema = $this->getTypeSchema(); + $visitor = new SqlCriterionVisitor($schema); + + $criterion = $criteria instanceof CriteriaBuilder + ? $criteria->criterion() + : $criteria; + + $builder = new \Horde\Db\Query\SelectBuilder($this->adapter); + $builder = $builder->from($schema->getTable()); + + $builder = $visitor->apply($builder, $criterion); + + if ($criteria instanceof CriteriaBuilder) { + foreach ($criteria->orderByList() as [$field, $direction]) { + $column = $schema->columnForField($field); + $builder = $builder->addOrderBy( + $column, + $direction === Direction::DESC ? 'DESC' : 'ASC', + ); + } + if ($criteria->getLimit() !== null) { + $builder = $builder->limit($criteria->getLimit()); + } + if ($criteria->getOffset() !== null) { + $builder = $builder->offset($criteria->getOffset()); + } + } + + $query = $builder->build(); + $rows = $this->adapter->selectAll($query->sql, $query->params); + + $results = []; + foreach ($rows as $row) { + $results[] = $this->map($row); + } + return $results; + } + + /** + * Count entities matching a Criterion or CriteriaBuilder. + * + * Requires the adapter to implement QuotingInterface. + * + * @param Criterion|CriteriaBuilder $criteria Search criteria. + * + * @return int Number of matching rows. + * + * @throws RdoException If adapter lacks QuotingInterface support. + */ + public function countByCriteria(Criterion|CriteriaBuilder $criteria): int + { + $this->requireQuotingInterface(); + + $schema = $this->getTypeSchema(); + $visitor = new SqlCriterionVisitor($schema); + + $criterion = $criteria instanceof CriteriaBuilder + ? $criteria->criterion() + : $criteria; + + $builder = new \Horde\Db\Query\SelectBuilder($this->adapter); + $builder = $builder + ->columns(new \Horde\Db\Query\Expression('COUNT(*) AS cnt')) + ->from($schema->getTable()); + + $builder = $visitor->apply($builder, $criterion); + + $query = $builder->build(); + $row = $this->adapter->selectOne($query->sql, $query->params); + + return (int) ($row['cnt'] ?? 0); + } + + /** + * Guard: verify that the adapter implements QuotingInterface. + * + * @throws RdoException If adapter does not implement QuotingInterface. + */ + private function requireQuotingInterface(): void + { + if (!($this->adapter instanceof QuotingInterface)) { + throw new RdoException( + 'Criterion-based queries require an adapter that implements ' + . QuotingInterface::class + . '. Use the DBAL query builder from Horde\Db.', + ); + } + } } diff --git a/src/MapperSql.php b/src/MapperSql.php index b1c1161..cb8215d 100644 --- a/src/MapperSql.php +++ b/src/MapperSql.php @@ -1,707 +1,28 @@ adapter = $adapter; - } - - /** - * Attach a Factory to the mapper. - * If called without arguments, detaches the mapper from factory - * - * @param Factory $factory A Factory instance or null - * @return Mapper this mapper - */ - public function setFactory(?Factory $factory = null) - { - $this->_factory = $factory; - return $this; - } - - /** - * Provide read-only, on-demand access to several properties. This - * method will only be called for properties that aren't already - * present; once a property is fetched once it is cached and - * returned directly on any subsequent access. - * - * These properties are available: - * - * adapter: The Horde_Db_Adapter this mapper is using to talk to - * the database. - * - * factory: The Factory instance, if present - * - * inflector: The Horde_Support_Inflector this Mapper uses to singularize - * and pluralize PHP class, database table, and database field/key names. - * - * table: The database table or view that this Mapper manages. - * - * tableDefinition: The Horde_Db_Adapter_Base_TableDefinition object describing - * the table or view this Mapper manages. - * - * fields: Array of all field names that are loaded up front - * (eager loading) from the table. - * - * lazyFields: Array of fields that are only loaded when accessed. - * - * relationships: Array of relationships to other Mappers. - * - * lazyRelationships: Array of relationships to other Mappers which - * are only loaded when accessed. - * - * @param string $key Property name to fetch - * - * @return mixed Value of $key - */ - public function __get($key) - { - switch ($key) { - case 'inflector': - $this->inflector = new Horde_Support_Inflector(); - return $this->inflector; - - case 'primaryKey': - $this->primaryKey = (string) $this->tableDefinition->getPrimaryKey(); - return $this->primaryKey; - - case 'table': - $this->table = !empty($this->_table) ? $this->_table : $this->mapperToTable(); - return $this->table; - - case 'tableDefinition': - $this->tableDefinition = $this->adapter->table($this->table); - return $this->tableDefinition; - - case 'fields': - $this->fields = array_diff($this->tableDefinition->getColumnNames(), $this->_lazyFields); - return $this->fields; - - case 'lazyFields': - case 'relationships': - case 'lazyRelationships': - case 'factory': - case 'defaultSort': - return $this->{'_' . $key}; - } - - return null; - } - - /** - * Create an instance of $this->_classname from a set of data. - * - * @param array $fields Field names/default values for the new object. - * - * @see $_classname - * - * @return Base An instance of $this->_classname with $fields - * as initial data. - */ - public function map($fields = []) - { - // Guess a classname if one isn't explicitly set. - if (!$this->_classname) { - $this->_classname = $this->mapperToEntity(); - if (!$this->_classname) { - throw new RdoException('Unable to find an entity class (extending Base) for ' . get_class($this)); - } - } - - $o = new $this->_classname(); - $o->setMapper($this); - - $this->mapFields($o, $fields); - - if (is_callable([$o, 'afterMap'])) { - $o->afterMap(); - } - - return $o; - } - - /** - * Update an instance of $this->_classname from a set of data. - * - * @param Base $object The object to update - * @param array $fields Field names/default values for the object - */ - public function mapFields($object, $fields = []) - { - $relationships = []; - foreach ($fields as $fieldName => &$fieldValue) { - if (strpos($fieldName, '@') !== false) { - [$rel, $field] = explode('@', $fieldName, 2); - $relationships[$rel][$field] = $fieldValue; - unset($fields[$fieldName]); - } - if (isset($this->fields[$fieldName])) { - $fieldName = $this->fields[$fieldName]; - } - $column = $this->tableDefinition->getColumn($fieldName); - if ($column) { - $fieldValue = $column->typeCast($fieldValue); - } - } - - $object->setFields($fields); - - if (count($relationships)) { - foreach ($this->relationships as $relationship => $rel) { - if (isset($rel['mapper'])) { - if ($this->_factory) { - $m = $this->_factory->create($rel['mapper']); - } - // @TODO - should be getting this instance from somewhere - // else external, and not passing the adapter along - // automatically. - else { - $m = new $rel['mapper']($this->adapter); - } - } else { - $m = $this->tableToMapper($relationship); - if (is_null($m)) { - // @TODO Throw an RdoException? - continue; - } - } - - // Don't check the table only. If there was a match for LEFT - // JOINs, there may be a "empty" result - if (isset($relationships[$m->table], $relationships[$m->table][$m->primaryKey])) { - $object->$relationship = $m->map($relationships[$m->table]); - } - } - } - } - - /** - * Transform a table name to a mapper class name. - * - * @param string $table The database table name to look up. - * - * @return Mapper A new Mapper instance if it exists, else null. - */ - public function tableToMapper($table) - { - if (class_exists(($class = Horde_String::ucwords($table) . 'Mapper'))) { - return new $class(); - } - return null; - } - - /** - * Transform this mapper's class name to a database table name. - * - * @return string The database table name. - */ - public function mapperToTable() - { - return $this->inflector->pluralize(Horde_String::lower(str_replace('Mapper', '', get_class($this)))); - } - - /** - * Transform this mapper's class name to an entity class name. - * - * @return string A Base concrete class name if the class exists, else null. - */ - public function mapperToEntity() - { - $class = str_replace('Mapper', '', get_class($this)); - if (class_exists($class)) { - return $class; - } - return null; - } - - /** - * Count objects that match $query. - * - * @param mixed $query The query to count matches of. - * - * @return integer All objects matching $query. - */ - public function count($query = null) - { - $query = BaseQuery::create($query, $this); - $query->setFields('COUNT(*)') - ->clearSort(); - [$sql, $bindParams] = $query->getQuery(); - return $this->adapter->selectValue($sql, $bindParams); - } - - /** - * Check if at least one object matches $query. - * - * @param mixed $query Either a primary key, an array of keys - * => values, or a Query object. - * - * @return boolean True or false. - */ - public function exists($query) - { - $query = BaseQuery::create($query, $this); - $query->setFields(1) - ->clearSort(); - [$sql, $bindParams] = $query->getQuery(); - return (bool) $this->adapter->selectValue($sql, $bindParams); - } - - /** - * Create a new object in the backend with $fields as initial values. - * - * @param array $fields Array of field names => initial values. - * - * @return Base The newly created object. - */ - public function create($fields) - { - // If configured to record creation and update times, set them - // here. We set updated_at to the initial creation time so it's - // always set. - if ($this->_setTimestamps) { - $time = time(); - $fields['created_at'] = $time; - $fields['updated_at'] = $time; - } - - // Filter out any extra fields. - $fields = array_intersect_key($fields, array_flip($this->tableDefinition->getColumnNames())); - - if (!$fields) { - throw new RdoException('create() requires at least one field value.'); - } - - $sql = 'INSERT INTO ' . $this->adapter->quoteTableName($this->table); - $keys = []; - $placeholders = []; - $bindParams = []; - foreach ($fields as $field => $value) { - $keys[] = $this->adapter->quoteColumnName($field); - $placeholders[] = '?'; - $bindParams[] = $value; - } - $sql .= ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $placeholders) . ')'; - - $id = $this->adapter->insert($sql, $bindParams); - - return $this->map(array_merge($fields, [$this->primaryKey => $id])); - } - - /** - * Updates a record in the backend. $object can be either a - * primary key or an Rdo object. If $object is an Rdo instance - * then $fields will be ignored as values will be pulled from the - * object. - * - * @param string|Rdo $object The Rdo instance or unique id to update. - * @param array $fields If passing a unique id, the array of field properties - * to set for $object. - * - * @return integer Number of objects updated. - */ - public function update($object, $fields = null) - { - if ($object instanceof Base) { - $key = $this->primaryKey; - $id = $object->$key; - $fields = iterator_to_array($object); - - if (!$id) { - // Object doesn't exist yet; create it instead. - $o = $this->create($fields); - $this->mapFields($object, iterator_to_array($o)); - return 1; - } - } else { - $id = $object; - } - - // If configured to record update time, set it here. - if ($this->_setTimestamps) { - $fields['updated_at'] = time(); - } - - // Filter out any extra fields. - $fields = array_intersect_key($fields, array_flip($this->tableDefinition->getColumnNames())); - - if (!$fields) { - // Nothing to change. - return 0; - } - - $sql = 'UPDATE ' . $this->adapter->quoteTableName($this->table) . ' SET'; - $bindParams = []; - foreach ($fields as $field => $value) { - $sql .= ' ' . $this->adapter->quoteColumnName($field) . ' = ?,'; - $bindParams[] = $value; - } - $sql = substr($sql, 0, -1) . ' WHERE ' . $this->primaryKey . ' = ?'; - $bindParams[] = $id; - - return $this->adapter->update($sql, $bindParams); - } - - /** - * Deletes a record from the backend. $object can be either a - * primary key, an Rdo_Query object, or an Rdo object. - * - * @param string|Base|Query $object The Rdo object, - * Query, or unique id to delete. - * - * @return integer Number of objects deleted. - */ - public function delete($object) - { - if ($object instanceof Base) { - $key = $this->primaryKey; - $id = $object->$key; - $query = [$key => $id]; - } elseif ($object instanceof Query) { - $query = $object; - } else { - $key = $this->primaryKey; - $query = [$key => $object]; - } - - $query = BaseQuery::create($query, $this); - - $clauses = []; - $bindParams = []; - foreach ($query->tests as $test) { - $clauses[] = $this->adapter->quoteColumnName($test['field']) . ' ' . $test['test'] . ' ?'; - $bindParams[] = $test['value']; - } - if (!$clauses) { - throw new RdoException('Refusing to delete the entire table.'); - } - - $sql = 'DELETE FROM ' . $this->adapter->quoteTableName($this->table) - . ' WHERE ' . implode(' ' . $query->conjunction . ' ', $clauses); - - return $this->adapter->delete($sql, $bindParams); - } - - /** - * find() can be called in several ways. - * - * Primary key mode: pass find() a numerically indexed array of primary - * keys, and it will return a list of the objects that correspond to those - * keys. - * - * If you pass find() no arguments, all objects of this type will be - * returned. - * - * If you pass find() an associative array, it will be turned into a - * Query object. - * - * If you pass find() a Query, it will return a list of all - * objects matching that query. - */ - public function find($arg = null) - { - if (is_null($arg)) { - $query = null; - } elseif (is_array($arg)) { - if (!count($arg)) { - throw new RdoException('No criteria found'); - } - - if (is_numeric(key($arg))) { - // Numerically indexed arrays are assumed to be an array of - // primary keys. - $query = new BaseQuery(); - $query->combineWith('OR'); - foreach ($argv[0] as $id) { - $query->addTest($this->primaryKey, '=', $id); - } - } else { - $query = $arg; - } - } else { - $query = $arg; - } - - // Build a full Query object. - $query = BaseQuery::create($query, $this); - return new DefaultList($query); - } - - /** - * Adds a relation. - * - * - For one-to-one relations, simply updates the relation field. - * - For one-to-many relations, updates the related object's relation field. - * - For many-to-many relations, adds an entry in the "through" table. - * - Performs a no-op if the peer is already related. - * - * @param string $relationship The relationship key in the mapper. - * @param Base $ours The object from this mapper to add the - * relation. - * @param Base $theirs The other object from any mapper to add - * the relation. - * - * @throws RdoException - */ - public function addRelation( - $relationship, - Base $ours, - Base $theirs - ) { - if ($ours->hasRelation($relationship, $theirs)) { - return; - } - - $ourKey = $this->primaryKey; - $theirKey = $theirs->mapper->primaryKey; - - if (isset($this->relationships[$relationship])) { - $rel = $this->relationships[$relationship]; - } elseif (isset($this->lazyRelationships[$relationship])) { - $rel = $this->lazyRelationships[$relationship]; - } else { - throw new RdoException('The requested relation is not defined in the mapper'); - } - - switch ($rel['type']) { - case Constants::ONE_TO_ONE: - case Constants::MANY_TO_ONE: - $ours->{$rel['foreignKey']} = $theirs->$theirKey; - $ours->save(); - break; - - case Constants::ONE_TO_MANY: - $theirs->{$rel['foreignKey']} = $ours->$ourKey; - $theirs->save(); - break; - - case Constants::MANY_TO_MANY: - $sql = sprintf( - 'INSERT INTO %s (%s, %s) VALUES (?, ?)', - $this->adapter->quoteTableName($rel['through']), - $this->adapter->quoteColumnName($ourKey), - $this->adapter->quoteColumnName($theirKey) - ); - try { - $this->adapter->insert($sql, [$ours->$ourKey, $theirs->$theirKey]); - } catch (Horde_Db_Exception $e) { - throw new RdoException($e); - } - break; - } - } - - /** - * Removes a relation to one of the relationships defined in the mapper. - * - * - For one-to-one and one-to-many relations, simply sets the relation - * field to 0. - * - For many-to-many, either deletes all relations to this object or just - * the relation to a given peer object. - * - Performs a no-op if the peer is already unrelated. - * - * This is a proxy to the mapper's removeRelation method. - * - * @param string $relationship The relationship key in the mapper. - * @param Base $ours The object from this mapper. - * @param Base $theirs The object to remove from the relation. - * @return integer the number of affected relations - * - * @throws RdoException - */ - public function removeRelation( - $relationship, - Base $ours, - ?Base $theirs = null - ) { - if (!$ours->hasRelation($relationship, $theirs)) { - return; - } - - $ourKey = $this->primaryKey; - - if (isset($this->relationships[$relationship])) { - $rel = $this->relationships[$relationship]; - } elseif (isset($this->lazyRelationships[$relationship])) { - $rel = $this->lazyRelationships[$relationship]; - } else { - throw new RdoException('The requested relation is not defined in the mapper'); - } - - switch ($rel['type']) { - case Constants::ONE_TO_ONE: - case Constants::MANY_TO_ONE: - $ours->{$rel['foreignKey']} = null; - $ours->save(); - return 1; - break; - - case Constants::ONE_TO_MANY: - $theirs->{$rel['foreignKey']} = null; - $theirs->save(); - return 1; - break; - - case Constants::MANY_TO_MANY: - $sql = sprintf( - 'DELETE FROM %s WHERE %s = ? ', - $this->adapter->quoteTableName($rel['through']), - $this->adapter->quoteColumnName($ourKey) - ); - $values = [$ours->$ourKey]; - if (!empty($theirs)) { - $theirKey = $theirs->mapper->primaryKey; - $sql .= sprintf( - ' AND %s = ?', - $this->adapter->quoteColumnName($theirKey) - ); - $values[] = $theirs->$theirKey; - } - try { - return $this->adapter->delete($sql, $values); - } catch (Horde_Db_Exception $e) { - throw new RdoException($e); - } - break; - } - } - - /** - * findOne can be called in several ways. - * - * Primary key mode: pass find() a single primary key, and it will return a - * single object matching that primary key. - * - * If you pass findOne() no arguments, the first object of this type will be - * returned. - * - * If you pass findOne() an associative array, it will be turned into a - * Query object. - * - * If you pass findOne() a Query, it will return the first object - * matching that query. - */ - public function findOne($arg = null) - { - if (is_null($arg)) { - $query = null; - } elseif (is_scalar($arg)) { - $query = [$this->primaryKey => $arg]; - } else { - $query = $arg; - } - - // Build a full Query object, and limit it to one result. - $query = BaseQuery::create($query, $this); - $query->limit(1); - - $list = new DefaultList($query); - return $list->current(); - } - - /** - * Set a default sort rule for all queries done with this Mapper. - * - * @param string $sort SQL sort fragment, such as 'updated DESC' - */ - public function sortBy($sort) - { - $this->_defaultSort = $sort; - return $this; - } } diff --git a/src/SqlRepository.php b/src/SqlRepository.php index b899aed..904182d 100644 --- a/src/SqlRepository.php +++ b/src/SqlRepository.php @@ -198,14 +198,12 @@ private function buildSelect(Criterion|CriteriaBuilder $criteria): SelectBuilder foreach ($this->schema->getEagerFields() as $field) { $columns[] = $field->columnName(); } - if (empty($columns)) { - $columns = ['*']; - } $builder = new SelectBuilder($this->adapter); - $builder = $builder - ->from($this->schema->getTable()) - ->columns(...$columns); + $builder = $builder->from($this->schema->getTable()); + if (!empty($columns)) { + $builder = $builder->columns(...$columns); + } // Apply criterion $builder = $this->visitor->apply($builder, $criterion); From cd94887c319313fd6bf0fc5ddde4661a2a6af041 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 12 Apr 2026 17:48:07 +0200 Subject: [PATCH 4/5] test: Cover BaseMapper --- test/Unit/BaseMapperTest.php | 526 +++++++++++++++++++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 test/Unit/BaseMapperTest.php diff --git a/test/Unit/BaseMapperTest.php b/test/Unit/BaseMapperTest.php new file mode 100644 index 0000000..cad8650 --- /dev/null +++ b/test/Unit/BaseMapperTest.php @@ -0,0 +1,526 @@ +getTypeSchema(); + + $this->assertInstanceOf(TypeSchema::class, $schema); + } + + public function testTypeSchemaReflectsTable(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $this->assertSame('contacts', $mapper->getTypeSchema()->getTable()); + } + + public function testTypeSchemaReflectsPrimaryKey(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $pk = $mapper->getTypeSchema()->getPrimaryKey(); + $this->assertNotNull($pk); + $this->assertSame('contact_id', $pk->name); + $this->assertTrue($pk->primary); + $this->assertSame(FieldType::INT, $pk->type); + } + + public function testTypeSchemaReflectsEagerFields(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $schema = $mapper->getTypeSchema(); + $name = $schema->getField('name'); + $this->assertNotNull($name); + $this->assertSame(FieldType::STRING, $name->type); + $this->assertFalse($name->lazy); + + $email = $schema->getField('email'); + $this->assertNotNull($email); + } + + public function testTypeSchemaReflectsLazyFields(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $bio = $mapper->getTypeSchema()->getField('bio'); + $this->assertNotNull($bio); + $this->assertTrue($bio->lazy); + } + + public function testTypeSchemaReflectsTimestamps(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $this->assertTrue($mapper->getTypeSchema()->hasTimestamps()); + } + + public function testTypeSchemaReflectsNoTimestamps(): void + { + $mapper = new TestNoTimestampMapper(new MapperStubAdapter()); + + $this->assertFalse($mapper->getTypeSchema()->hasTimestamps()); + } + + // --- getTypeSchema: relationships --- + + public function testTypeSchemaReflectsHasManyRelation(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $rel = $mapper->getTypeSchema()->getRelation('orders'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::HAS_MANY, $rel->type); + $this->assertSame('OrderMapper', $rel->targetClass); + $this->assertSame('contact_id', $rel->foreignKey); + } + + public function testTypeSchemaReflectsBelongsToRelation(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $rel = $mapper->getTypeSchema()->getRelation('company'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::BELONGS_TO, $rel->type); + $this->assertSame('CompanyMapper', $rel->targetClass); + $this->assertSame('company_id', $rel->foreignKey); + } + + public function testTypeSchemaReflectsManyToManyRelation(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $rel = $mapper->getTypeSchema()->getRelation('tags'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::MANY_TO_MANY, $rel->type); + $this->assertSame('TagMapper', $rel->targetClass); + $this->assertSame('contact_tags', $rel->through); + } + + public function testTypeSchemaReflectsLazyRelation(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + // 'notes' is in $_lazyRelationships + $rel = $mapper->getTypeSchema()->getRelation('notes'); + $this->assertNotNull($rel); + $this->assertSame(RelationType::HAS_MANY, $rel->type); + } + + public function testTypeSchemaCachesResult(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $schema1 = $mapper->getTypeSchema(); + $schema2 = $mapper->getTypeSchema(); + + $this->assertSame($schema1, $schema2); + } + + // --- getTypeSchema: entity class --- + + public function testTypeSchemaReflectsEntityClass(): void + { + $mapper = new TestContactMapper(new MapperStubAdapter()); + + $this->assertSame(TestContact::class, $mapper->getTypeSchema()->getEntityClass()); + } + + // --- MapperSql inherits behavior --- + + public function testMapperSqlSubclassGetsTypeSchema(): void + { + $mapper = new TestMapperSqlChild(new MapperStubAdapter()); + + $schema = $mapper->getTypeSchema(); + $this->assertSame('items', $schema->getTable()); + $this->assertSame('item_id', $schema->getPrimaryKey()->name); + } + + // --- findByCriteria --- + + public function testFindByCriteriaReturnsEntities(): void + { + $adapter = new MapperStubAdapter([ + 'selectAll' => [ + ['contact_id' => '1', 'name' => 'Alice', 'email' => 'alice@x.com'], + ['contact_id' => '2', 'name' => 'Bob', 'email' => null], + ], + ]); + + $mapper = new TestContactMapper($adapter); + $results = $mapper->findByCriteria(Field::equals('name', 'Alice')); + + $this->assertIsArray($results); + $this->assertCount(2, $results); + $this->assertInstanceOf(TestContact::class, $results[0]); + } + + public function testFindByCriteriaPassesSql(): void + { + $adapter = new MapperStubAdapter(['selectAll' => []]); + + $mapper = new TestContactMapper($adapter); + $mapper->findByCriteria(Field::equals('name', 'Alice')); + + $call = $adapter->lastCall('selectAll'); + $this->assertNotNull($call); + $this->assertStringContainsString('"contacts"', $call['sql']); + $this->assertStringContainsString('"name" = ?', $call['sql']); + $this->assertSame(['Alice'], $call['params']); + } + + public function testFindByCriteriaWithCriteriaBuilder(): void + { + $adapter = new MapperStubAdapter([ + 'selectAll' => [ + ['contact_id' => '3', 'name' => 'Charlie', 'email' => 'c@x.com'], + ], + ]); + + $mapper = new TestContactMapper($adapter); + $criteria = CriteriaBuilder::from(Field::equals('name', 'Charlie')) + ->limit(10) + ->offset(5); + + $results = $mapper->findByCriteria($criteria); + + $this->assertCount(1, $results); + } + + public function testFindByCriteriaThrowsWithoutQuotingInterface(): void + { + $adapter = $this->createStub(Horde_Db_Adapter::class); + + $mapper = new TestContactMapperLegacyAdapter($adapter); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/QuotingInterface/'); + + $mapper->findByCriteria(Field::equals('name', 'Alice')); + } + + // --- countByCriteria --- + + public function testCountByCriteriaReturnsCount(): void + { + $adapter = new MapperStubAdapter([ + 'selectOne' => ['cnt' => '7'], + ]); + + $mapper = new TestContactMapper($adapter); + $count = $mapper->countByCriteria(Field::equals('name', 'Alice')); + + $this->assertSame(7, $count); + } + + public function testCountByCriteriaPassesSql(): void + { + $adapter = new MapperStubAdapter(['selectOne' => ['cnt' => '0']]); + + $mapper = new TestContactMapper($adapter); + $mapper->countByCriteria(Field::equals('name', 'Alice')); + + $call = $adapter->lastCall('selectOne'); + $this->assertNotNull($call); + $this->assertStringContainsString('COUNT(*)', $call['sql']); + $this->assertStringContainsString('"name" = ?', $call['sql']); + } + + public function testCountByCriteriaThrowsWithoutQuotingInterface(): void + { + $adapter = $this->createStub(Horde_Db_Adapter::class); + + $mapper = new TestContactMapperLegacyAdapter($adapter); + + $this->expectException(RdoException::class); + $this->expectExceptionMessageMatches('/QuotingInterface/'); + + $mapper->countByCriteria(Field::equals('name', 'Alice')); + } + + public function testCountByCriteriaWithCriteriaBuilder(): void + { + $adapter = new MapperStubAdapter(['selectOne' => ['cnt' => '3']]); + + $mapper = new TestContactMapper($adapter); + $criteria = CriteriaBuilder::from(Field::equals('name', 'Alice')); + + $this->assertSame(3, $mapper->countByCriteria($criteria)); + } +} + +// --- Test entity --- + +class TestContact extends Base +{ +} + +// --- Test mapper using BaseMapper directly --- + +class TestContactMapper extends BaseMapper +{ + protected $_table = 'contacts'; + protected $_classname = TestContact::class; + protected $_setTimestamps = true; + protected $_lazyFields = ['bio']; + + protected $_relationships = [ + 'company' => [ + 'type' => Constants::MANY_TO_ONE, + 'mapper' => 'CompanyMapper', + 'foreignKey' => 'company_id', + ], + 'tags' => [ + 'type' => Constants::MANY_TO_MANY, + 'mapper' => 'TagMapper', + 'through' => 'contact_tags', + ], + ]; + + protected $_lazyRelationships = [ + 'orders' => [ + 'type' => Constants::ONE_TO_MANY, + 'mapper' => 'OrderMapper', + 'foreignKey' => 'contact_id', + ], + 'notes' => [ + 'type' => Constants::ONE_TO_MANY, + 'mapper' => 'NoteMapper', + 'foreignKey' => 'contact_id', + ], + ]; +} + +// --- Test mapper extending MapperSql (backward compat) --- + +class TestMapperSqlChild extends MapperSql +{ + protected $_table = 'items'; + protected $_classname = TestContact::class; + protected $_setTimestamps = false; +} + +// --- Mapper with no timestamps --- + +class TestNoTimestampMapper extends BaseMapper +{ + protected $_table = 'items'; + protected $_classname = TestContact::class; + protected $_setTimestamps = false; +} + +// --- Mapper with legacy adapter (no QuotingInterface) --- + +class TestContactMapperLegacyAdapter extends BaseMapper +{ + protected $_table = 'contacts'; + protected $_classname = TestContact::class; +} + +// --- Stub table definition --- + +class StubTableDefinition +{ + private string $primaryKey; + private array $columns; + + public function __construct(string $primaryKey, array $columns) + { + $this->primaryKey = $primaryKey; + $this->columns = $columns; + } + + public function getPrimaryKey(): string + { + return $this->primaryKey; + } + + public function getColumnNames(): array + { + return $this->columns; + } + + public function getColumn(string $name): ?StubColumn + { + return in_array($name, $this->columns) ? new StubColumn() : null; + } +} + +class StubColumn +{ + public function typeCast(mixed $value): mixed + { + return $value; + } +} + +// --- Stub adapter that supports QuotingInterface + records calls --- + +class MapperStubAdapter implements Adapter, QuotingInterface +{ + private array $calls = []; + private array $overrides; + private ?StubTableDefinition $tableDefinition = null; + + public function __construct(array $overrides = []) + { + $this->overrides = $overrides; + } + + public function lastCall(string $method): ?array + { + foreach (array_reverse($this->calls) as $call) { + if ($call['method'] === $method) { + return $call; + } + } + return null; + } + + // --- Table definition (required by BaseMapper::__get) --- + + public function table($tableName) + { + if ($this->tableDefinition === null) { + // Default: contacts table with typical columns + $this->tableDefinition = new StubTableDefinition( + 'contact_id', + ['contact_id', 'name', 'email', 'bio'], + ); + if ($tableName === 'items') { + $this->tableDefinition = new StubTableDefinition( + 'item_id', + ['item_id', 'title'], + ); + } + } + return $this->tableDefinition; + } + + // --- QuotingInterface --- + + public function quoteColumnName(string $name): string + { + return '"' . $name . '"'; + } + + public function quoteTableName(string $name): string + { + return '"' . $name . '"'; + } + + public function quoteTrue(): string + { + return '1'; + } + + public function quoteFalse(): string + { + return '0'; + } + + public function addLimitOffset($sql, $options) + { + if (!empty($options['limit'])) { + $sql .= ' LIMIT ' . (int) $options['limit']; + } + if (!empty($options['offset'])) { + $sql .= ' OFFSET ' . (int) $options['offset']; + } + return $sql; + } + + public function addLock(&$sql, array $options = []) + { + } + + // --- Query methods --- + + public function selectAll($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'selectAll', 'sql' => $sql, 'params' => $arg1 ?? []]; + return $this->overrides['selectAll'] ?? []; + } + + public function selectOne($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'selectOne', 'sql' => $sql, 'params' => $arg1 ?? []]; + return $this->overrides['selectOne'] ?? null; + } + + public function insertBlob($table, $fields, $pk = null, $idValue = null) + { + $this->calls[] = ['method' => 'insertBlob', 'table' => $table, 'fields' => $fields]; + return 1; + } + + public function update($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'update', 'sql' => $sql, 'params' => $arg1 ?? []]; + return 1; + } + + public function delete($sql, $arg1 = null, $arg2 = null) + { + $this->calls[] = ['method' => 'delete', 'sql' => $sql, 'params' => $arg1 ?? []]; + return 1; + } + + // --- Unused interface methods --- + + public function adapterName() { return 'stub'; } + public function supportsMigrations() { return false; } + public function supportsCountDistinct() { return true; } + public function prefetchPrimaryKey($tableName = null) { return false; } + public function connect() {} + public function isActive() { return true; } + public function reconnect() {} + public function disconnect() {} + public function rawConnection() { return null; } + public function quoteString($string) { return "'" . addslashes($string) . "'"; } + public function select($sql, $arg1 = null, $arg2 = null) { return null; } + public function selectValue($sql, $arg1 = null, $arg2 = null) { return null; } + public function selectValues($sql, $arg1 = null, $arg2 = null) { return []; } + public function selectAssoc($sql, $arg1 = null, $arg2 = null) { return []; } + public function execute($sql, $arg1 = null, $arg2 = null) { return null; } + public function insert($sql, $arg1 = null, $arg2 = null, $pk = null, $idValue = null, $sequenceName = null) { return 1; } + public function updateBlob($table, $fields, $where = '') {} + public function transactionStarted() { return false; } + public function beginDbTransaction() {} + public function commitDbTransaction() {} + public function rollbackDbTransaction() {} + public function getLastQuery(): string { return ''; } + public function cacheWrite(string $key, string $value) {} + public function cacheRead($key) { return false; } +} From f1a4bb2aacf27b01a8d63e3d8c639c1b7830571e Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 12 Apr 2026 17:55:22 +0200 Subject: [PATCH 5/5] docs: Add upgrading documentation --- doc/USAGE.md | 841 ++++++++++++++++++++++++++++++++++++++ doc/migration-example.php | 265 ++++++++++++ phpunit.xml.dist | 1 + 3 files changed, 1107 insertions(+) create mode 100644 doc/USAGE.md create mode 100644 doc/migration-example.php diff --git a/doc/USAGE.md b/doc/USAGE.md new file mode 100644 index 0000000..2c67845 --- /dev/null +++ b/doc/USAGE.md @@ -0,0 +1,841 @@ +# Horde\Rdo Usage Guide + +This guide covers the new-style Rdo API: persistence-agnostic entities, typed +schemas, abstract criteria, and the repository pattern. These replace the +classic `Horde_Rdo_Base` / `Horde_Rdo_Mapper` / `Horde_Rdo_Query` active-record +classes for new code. + +All classes live in the `Horde\Rdo` namespace (PSR-4, under `src/`). + +--- + +## Table of Contents + +1. [Entities](#entities) +2. [TypeSchema](#typeschema) + - [Fluent Builder](#fluent-builder) + - [PHP Attributes](#php-attributes) +3. [Criteria](#criteria) + - [Field Conditions](#field-conditions) + - [Combining Criteria](#combining-criteria) + - [Negation](#negation) + - [Relationship Existence](#relationship-existence) + - [Raw Expressions](#raw-expressions) + - [CriteriaBuilder (Ordering, Pagination, Eager Loading)](#criteriabuilder) +4. [Repository](#repository) + - [Finding Entities](#finding-entities) + - [Counting and Existence](#counting-and-existence) + - [Saving](#saving) + - [Deleting](#deleting) + - [Raw Queries](#raw-queries) +5. [Hydrator](#hydrator) +6. [TypeRegistry](#typeregistry) +7. [Wiring It Together](#wiring-it-together) +8. [Field Types and Casting](#field-types-and-casting) +9. [Relationship Types](#relationship-types) +10. [Migration from Classic Rdo](#migration-from-classic-rdo) + +--- + +## Entities + +An entity is a plain PHP class. It has no base class, no interface to +implement, no magic methods. The schema describes how it maps to storage. + +```php +class Contact +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public ?string $email = null, + public ?string $phone = null, + ) {} +} +``` + +Constructor promotion, readonly properties, and nullable types all work. The +hydrator fills constructor parameters first, then sets any remaining public +properties directly. + +--- + +## TypeSchema + +A `TypeSchema` describes an entity's fields, relationships, and table mapping. +There are two equivalent ways to build one: the fluent builder API and PHP +attributes. + +### Fluent Builder + +```php +use Horde\Rdo\FieldType; +use Horde\Rdo\TypeSchema; + +$schema = (new TypeSchema(Contact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true) + ->field('phone', FieldType::STRING, nullable: true, column: 'phone_number') + ->field('bio', FieldType::TEXT, lazy: true) + ->hasMany('orders', Order::class, 'contact_id') + ->belongsTo('company', Company::class, 'company_id') + ->manyToMany('groups', Group::class, 'contact_groups', 'contact_id') + ->timestamps(); +``` + +**`id()`** declares the primary key. Defaults to `'id'` with `FieldType::INT`. +Pass a third argument for a custom column name. + +**`field()`** declares a regular column. Named parameters control behavior: + +| Parameter | Default | Effect | +|------------|---------|--------| +| `nullable` | `false` | Allows null values | +| `lazy` | `false` | Excluded from default SELECT (loaded on demand) | +| `column` | `null` | Backend column name if different from the field name | + +If `table` is omitted from the constructor, it defaults to the lowercased short +class name (e.g., `App\Entity\Contact` becomes `contact`). + +**`timestamps()`** enables automatic `created_at` / `updated_at` handling on +save. + +### PHP Attributes + +The same schema can be expressed with PHP 8 attributes on the entity class +itself: + +```php +use Horde\Rdo\Attribute\BelongsTo; +use Horde\Rdo\Attribute\Column; +use Horde\Rdo\Attribute\HasMany; +use Horde\Rdo\Attribute\Id; +use Horde\Rdo\Attribute\ManyToMany; +use Horde\Rdo\Attribute\Table; +use Horde\Rdo\FieldType; + +#[Table(name: 'contacts', timestamps: true)] +class Contact +{ + #[Id(type: FieldType::INT)] + public int $id; + + #[Column] + public string $name; + + #[Column(nullable: true)] + public ?string $email = null; + + #[Column(name: 'phone_number', nullable: true)] + public ?string $phone = null; + + #[Column(type: FieldType::TEXT, lazy: true)] + public string $bio; + + #[BelongsTo(target: Company::class, foreignKey: 'company_id')] + public ?Company $company = null; + + #[HasMany(target: Order::class, foreignKey: 'contact_id')] + public iterable $orders; + + #[ManyToMany(target: Group::class, through: 'contact_groups')] + public iterable $groups; +} +``` + +Read the schema with `AttributeSchemaReader`: + +```php +use Horde\Rdo\AttributeSchemaReader; + +$reader = new AttributeSchemaReader(); +$schema = $reader->read(Contact::class); +``` + +When `#[Column]` or `#[Id]` omits the `type`, the reader infers it from the +PHP type declaration: `int` maps to `INT`, `float` to `FLOAT`, `bool` to +`BOOL`, `array` to `JSON`, and `DateTimeImmutable`/`DateTime`/`DateTimeInterface` +to `DATETIME`. Everything else defaults to `STRING`. + +#### Available Attributes + +| Attribute | Target | Parameters | +|----------------|----------|------------| +| `#[Table]` | Class | `name`, `timestamps` (default `false`) | +| `#[Id]` | Property | `type` (auto-detected), `column` | +| `#[Column]` | Property | `type` (auto-detected), `name` (column), `nullable`, `lazy` | +| `#[HasOne]` | Property | `target`, `foreignKey`, `lazy` (default `true`) | +| `#[HasMany]` | Property | `target`, `foreignKey`, `lazy` (default `true`) | +| `#[BelongsTo]` | Property | `target`, `foreignKey`, `lazy` (default `true`) | +| `#[ManyToMany]`| Property | `target`, `through`, `foreignKey`, `lazy` (default `true`) | + +--- + +## Criteria + +Criteria are the query language. A `Criterion` is either a leaf (single +condition) or a composite (logical combination). The tree IS the query -- +backend translation happens separately through the Visitor pattern. + +### Field Conditions + +The `Field` class provides named constructors for every comparison: + +```php +use Horde\Rdo\Field; + +Field::equals('status', 'active'); +Field::notEquals('role', 'guest'); +Field::greaterThan('age', 18); +Field::lessThan('price', 100); +Field::greaterOrEqual('score', 80); +Field::lessOrEqual('priority', 3); +Field::in('status', ['active', 'pending']); +Field::notIn('role', ['banned', 'suspended']); +Field::isNull('deleted_at'); +Field::isNotNull('email'); +Field::contains('name', 'smith'); // LIKE '%smith%' +Field::startsWith('name', 'A'); // LIKE 'A%' +Field::between('created_at', '2026-01-01', '2026-12-31'); +Field::search('bio', 'horde groupware'); // Full-text search intent +``` + +Each returns an immutable `FieldCriterion`. Field names refer to the entity's +logical field names as defined in the TypeSchema, not raw column names. + +### Combining Criteria + +Use `All` (AND) and `Any` (OR) to compose: + +```php +use Horde\Rdo\All; +use Horde\Rdo\Any; +use Horde\Rdo\Field; + +// Active users over 18 +$criteria = All::of( + Field::equals('status', 'active'), + Field::greaterThan('age', 18), +); + +// Admin or editor +$criteria = Any::of( + Field::equals('role', 'admin'), + Field::equals('role', 'editor'), +); +``` + +Composites nest naturally: + +```php +// Active users who are either admin or have verified email +$criteria = All::of( + Field::equals('status', 'active'), + Any::of( + Field::equals('role', 'admin'), + Field::isNotNull('verified_at'), + ), +); +``` + +### Negation + +```php +use Horde\Rdo\Not; + +// Not deleted +Not::of(Field::isNotNull('deleted_at')); + +// Not in the banned list AND not a guest +All::of( + Not::of(Field::in('id', $bannedIds)), + Not::of(Field::equals('role', 'guest')), +); +``` + +### Relationship Existence + +Query entities based on whether a relationship exists, optionally with +conditions on the related entity: + +```php +use Horde\Rdo\Has; + +// Contacts who have at least one order +Has::relation('orders'); + +// Contacts who have orders over $100 +Has::relation('orders', Field::greaterThan('amount', 100)); +``` + +The relationship name must match a relation defined in the TypeSchema. + +### Raw Expressions + +Escape hatch for backend-specific queries that can't be expressed through +the abstract Criterion API: + +```php +use Horde\Rdo\Raw; + +// PostGIS spatial query +Raw::sql('ST_DWithin(location, ST_MakePoint(?, ?), ?)', [$lng, $lat, $radius]); + +// Database-specific function +Raw::sql('MATCH(bio) AGAINST(? IN BOOLEAN MODE)', [$searchTerms]); +``` + +Raw criteria are passed directly to the backend. They tie your query to a +specific backend -- use sparingly. + +### CriteriaBuilder + +Wrap any `Criterion` with ordering, pagination, and eager-loading hints: + +```php +use Horde\Rdo\CriteriaBuilder; +use Horde\Rdo\Direction; +use Horde\Rdo\Field; + +$query = CriteriaBuilder::from(Field::equals('status', 'active')) + ->orderBy('name') + ->orderBy('created_at', Direction::DESC) + ->limit(20) + ->offset(40) + ->with('company', 'groups'); +``` + +`CriteriaBuilder` is immutable -- each method returns a new instance. Pass +either a bare `Criterion` or a `CriteriaBuilder` to repository methods; both +are accepted. + +--- + +## Repository + +`Repository` is the consumer-facing interface for entity persistence: + +```php +use Horde\Rdo\Repository; + +interface Repository +{ + public function find(Criterion|CriteriaBuilder $criteria): iterable; + public function findOne(mixed $id): ?object; + public function count(Criterion|CriteriaBuilder $criteria): int; + public function exists(mixed $id): bool; + public function save(object $entity): void; + public function delete(object $entity): void; + public function findRaw(mixed $backendQuery, array $params = []): iterable; +} +``` + +`SqlRepository` is the SQL-backed implementation that connects criteria to +the Horde DBAL query builder. + +### Finding Entities + +```php +// Find by criteria +$contacts = $repo->find(Field::equals('status', 'active')); + +// With ordering and pagination +$contacts = $repo->find( + CriteriaBuilder::from(Field::equals('status', 'active')) + ->orderBy('name') + ->limit(25), +); + +// Find one by primary key +$contact = $repo->findOne(42); + +// Returns null if not found +$contact = $repo->findOne(999); // null +``` + +### Counting and Existence + +```php +$count = $repo->count(Field::equals('status', 'active')); + +$exists = $repo->exists(42); // bool +``` + +### Saving + +`save()` determines insert vs. update automatically by checking whether the +primary key value already exists in the backend: + +```php +// Insert (new entity, no existing PK in DB) +$contact = new Contact(0, 'Alice', 'alice@example.com'); +$repo->save($contact); + +// Update (existing PK found in DB) +$contact = $repo->findOne(42); +$contact->email = 'new@example.com'; +$repo->save($contact); +``` + +When timestamps are enabled, `save()` sets `updated_at` on every save and +`created_at` on inserts. + +### Deleting + +```php +$contact = $repo->findOne(42); +$repo->delete($contact); +``` + +Requires a primary key defined in the schema. + +### Raw Queries + +Escape hatch for queries that bypass the Criteria layer entirely: + +```php +$results = $repo->findRaw( + 'SELECT * FROM contacts WHERE name LIKE ? ORDER BY id', + ['%smith%'], +); +``` + +Results are still hydrated through the normal Hydrator. + +--- + +## Hydrator + +A `Hydrator` converts between raw data arrays (database rows) and entity +objects. Two implementations are provided: + +**`DefaultHydrator`** -- For typed entity classes. Uses reflection to fill +constructor parameters and set properties. Handles type casting between +database and PHP types based on the FieldType in the schema. + +```php +use Horde\Rdo\DefaultHydrator; + +$hydrator = new DefaultHydrator(); + +// Row from database -> entity object +$contact = $hydrator->hydrate( + ['id' => '42', 'name' => 'Alice', 'email' => 'alice@example.com'], + $schema, +); +// $contact->id === 42 (cast from string to int via FieldType::INT) + +// Entity object -> data array for persistence +$data = $hydrator->extract($contact, $schema); +// ['id' => 42, 'name' => 'Alice', 'email' => 'alice@example.com'] +``` + +**`FieldBagHydrator`** -- For dynamic entities that use `ArrayAccess` or +public property access. No type casting. Intended as a migration path for +legacy Rdo entities. + +```php +use Horde\Rdo\FieldBagHydrator; + +$hydrator = new FieldBagHydrator(); +``` + +When the TypeSchema defines a column-to-field name mapping, both hydrators +translate between backend column names and entity field names automatically. + +--- + +## TypeRegistry + +The `TypeRegistry` is the central coordination point that maps entity classes +to their schemas and repositories: + +```php +use Horde\Rdo\TypeRegistry; + +$registry = new TypeRegistry(); + +$registry->register($contactSchema, $contactRepo); +$registry->register($orderSchema, $orderRepo); + +// Later, look up by entity class +$schema = $registry->getSchema(Contact::class); +$repo = $registry->getRepository(Contact::class); + +$registry->has(Contact::class); // true +$registry->has(Unknown::class); // false +``` + +Populate it at application boot (typically via the dependency injector). + +--- + +## Wiring It Together + +A complete setup using the fluent schema builder: + +```php +use Horde\Rdo\DefaultHydrator; +use Horde\Rdo\FieldType; +use Horde\Rdo\SqlRepository; +use Horde\Rdo\TypeRegistry; +use Horde\Rdo\TypeSchema; + +// 1. Define the schema +$schema = (new TypeSchema(Contact::class, 'contacts')) + ->id('id', FieldType::INT) + ->field('name', FieldType::STRING) + ->field('email', FieldType::STRING, nullable: true) + ->hasMany('orders', Order::class, 'contact_id') + ->timestamps(); + +// 2. Create hydrator and repository +$hydrator = new DefaultHydrator(); +$repo = new SqlRepository($dbAdapter, $schema, $hydrator); + +// 3. Register (optional, for central lookup) +$registry = new TypeRegistry(); +$registry->register($schema, $repo); + +// 4. Use it +$contacts = $repo->find( + CriteriaBuilder::from(Field::equals('status', 'active')) + ->orderBy('name') + ->limit(10), +); + +foreach ($contacts as $contact) { + echo $contact->name . "\n"; +} +``` + +Or using PHP attributes: + +```php +use Horde\Rdo\AttributeSchemaReader; +use Horde\Rdo\DefaultHydrator; +use Horde\Rdo\SqlRepository; + +// 1. Read schema from attributes on the entity class +$reader = new AttributeSchemaReader(); +$schema = $reader->read(Contact::class); + +// 2. Create hydrator and repository +$hydrator = new DefaultHydrator(); +$repo = new SqlRepository($dbAdapter, $schema, $hydrator); +``` + +--- + +## Field Types and Casting + +The `DefaultHydrator` casts values between backend (typically string) and PHP +types based on the `FieldType` in the schema: + +| FieldType | PHP Type | Backend Format | +|----------------|----------------------|---------------------------------| +| `STRING` | `string` | Passed through | +| `INT` | `int` | Cast via `(int)` | +| `FLOAT` | `float` | Cast via `(float)` | +| `BOOL` | `bool` | `1` / `0` in backend | +| `DATETIME` | `DateTimeImmutable` | `Y-m-d H:i:s` string | +| `TEXT` | `string` | Passed through (same as STRING) | +| `BINARY` | `string` | Passed through | +| `JSON` | `array` | JSON string in backend | +| `SERIALIZED` | `mixed` | PHP `serialize()` string | + +`TEXT` and `BINARY` behave like `STRING` for casting but carry semantic meaning +for schema introspection and lazy-loading decisions. + +--- + +## Relationship Types + +| Type | Schema Method | Attribute | FK Location | +|----------------|----------------|-----------------|-----------------| +| Has One | `hasOne()` | `#[HasOne]` | On target table | +| Has Many | `hasMany()` | `#[HasMany]` | On target table | +| Belongs To | `belongsTo()` | `#[BelongsTo]` | On source table | +| Many to Many | `manyToMany()` | `#[ManyToMany]` | Pivot table | + +All relationships default to `lazy: true`. Set `lazy: false` for eager +loading in the schema definition. + +When using `Has::relation()` in criteria, the SQL visitor generates +`WHERE EXISTS` subqueries based on the relationship metadata in the +TypeSchema. + +--- + +## Migration from Classic Rdo + +The new classes coexist with the classic `Horde_Rdo_Base` / `Horde_Rdo_Mapper` +/ `Horde_Rdo_Query` classes under `lib/`. Existing code continues to work +unchanged. Migrate either to the MapperSql drop-in replacement or to the all-new SqlRepository. +The former is straight forward and mechanical. +The latter is a more involved upgrade which unlocks more control. +See migration-example.php for comparison. + +### Concept Mapping + +Before looking at code: This is how classic Rdo concepts translate: + +| Classic Rdo | New-style equivalent | +|----------------------------|-------------------------------| +| `Horde_Rdo_Base` subclass | Plain PHP class (entity) | +| `Horde_Rdo_Mapper` subclass| `SqlRepository` | +| `Horde_Rdo_Query` | `Criterion` / `CriteriaBuilder` | +| `$_table` | `TypeSchema::getTable()` | +| `$_relationships` | `hasOne()`, `hasMany()`, etc. | +| `$_lazyRelationships` | Relationships with `lazy: true` | +| `$_lazyFields` | Fields with `lazy: true` | +| `$_setTimestamps` | `timestamps()` | +| `Horde_Rdo_Factory` | `TypeRegistry` | +| `addTest($field, $op, $v)` | `Field::equals()`, etc. | +| `sortBy('name ASC')` | `->orderBy('name')` | +| `$mapper->find($query)` | `$repo->find($criteria)` | +| `$mapper->findOne($id)` | `$repo->findOne($id)` | +| `$mapper->count($query)` | `$repo->count($criteria)` | + +### Step 1: Verbatim Conversion + +The first step is a mechanical translation. Take what the mapper and entity +already do and express it with the new classes, without changing behavior. + +**Classic entity** (extends `Horde_Rdo_Base`, stores fields in `$_fields` +array, accessed via `__get` / `__set`): + +```php +class Sesha_Entity_Stock extends Horde_Rdo_Base +{ + // All fields come from the database via __get +} +``` + +**Classic mapper** (extends `Horde_Rdo_Mapper`, declares table, relationships, +and custom logic): + +```php +class Sesha_Entity_StockMapper extends Horde_Rdo_Mapper +{ + protected $_table = 'sesha_inventory'; + + protected $_lazyRelationships = [ + 'categories' => [ + 'type' => Horde_Rdo::MANY_TO_MANY, + 'mapper' => 'Sesha_Entity_CategoryMapper', + 'through' => 'sesha_inventory_categories', + ], + 'values' => [ + 'type' => Horde_Rdo::ONE_TO_MANY, + 'mapper' => 'Sesha_Entity_ValueMapper', + 'foreignKey' => 'stock_id', + ], + ]; +} +``` + +**Verbatim new-style equivalent** -- the entity becomes a plain class and the +mapper becomes a TypeSchema + SqlRepository pair. Use `FieldBagHydrator` to +preserve the dynamic property behavior during transition: + +```php +// Entity -- plain class, no base class +class Stock +{ + public int $stock_id; + public string $stock_name = ''; + public string $note = ''; +} +``` + +```php +// Schema -- transcribes what the mapper declared +$schema = (new TypeSchema(Stock::class, 'sesha_inventory')) + ->id('stock_id', FieldType::INT) + ->field('stock_name', FieldType::STRING) + ->field('note', FieldType::STRING, nullable: true) + ->hasMany('values', Value::class, 'stock_id') + ->manyToMany('categories', Category::class, 'sesha_inventory_categories'); +``` + +```php +// Repository -- replaces the mapper class +$hydrator = new FieldBagHydrator(); // or DefaultHydrator for typed entities +$repo = new SqlRepository($adapter, $schema, $hydrator); +``` + +At this point calling code changes are minimal: + +```php +// Before +$mapper = new Sesha_Entity_StockMapper($adapter); +$stock = $mapper->findOne($stockId); + +// After +$stock = $repo->findOne($stockId); +``` + +```php +// Before +$query = new Horde_Rdo_Query($mapper); +$query->addTest('stock_name', 'LIKE', '%server%'); +$results = $mapper->find($query); + +// After +$results = $repo->find(Field::contains('stock_name', 'server')); +``` + +### Step 2: Adopt Typed Entities + +Once the verbatim conversion is working you can optionally move to fully +typed entities with constructor promotion and switch to `DefaultHydrator` +for automatic type casting: + +```php +class Stock +{ + public function __construct( + public readonly int $stock_id, + public string $stock_name, + public ?string $note = null, + ) {} +} +``` + +```php +$hydrator = new DefaultHydrator(); +$repo = new SqlRepository($adapter, $schema, $hydrator); + +$stock = $repo->findOne(42); +// $stock->stock_id is int, not string -- DefaultHydrator casts it +``` + +### Step 3: Explore New Capabilities (When Ready) + +The criteria system, relationship queries and attribute-based schemas are +available when you want them. There is no requirement to adopt everything +at once. + +**Composable criteria** replace chains of `addTest()`: + +```php +// Before +$query = new Horde_Rdo_Query($mapper); +$query->addTest('stock_name', 'LIKE', '%server%'); +$query->addTest('note', '!=', ''); +$query->sortBy('stock_name ASC'); +$query->limit(25, 50); +$results = $mapper->find($query); + +// After +$results = $repo->find( + CriteriaBuilder::from(All::of( + Field::contains('stock_name', 'server'), + Field::notEquals('note', ''), + )) + ->orderBy('stock_name') + ->limit(25) + ->offset(50), +); +``` + +**Relationship existence queries** replace manual JOINs or subqueries: + +```php +// Stocks that have at least one value assigned +$results = $repo->find(Has::relation('values')); + +// Stocks in a specific category (via the pivot table) +$results = $repo->find( + Has::relation('categories', Field::equals('category_id', 5)), +); +``` + +**Attribute-based schemas** move the mapping onto the entity class itself, +eliminating the separate schema setup: + +```php +use Horde\Rdo\Attribute\Column; +use Horde\Rdo\Attribute\HasMany; +use Horde\Rdo\Attribute\Id; +use Horde\Rdo\Attribute\ManyToMany; +use Horde\Rdo\Attribute\Table; +use Horde\Rdo\FieldType; + +#[Table(name: 'sesha_inventory')] +class Stock +{ + #[Id(type: FieldType::INT)] + public int $stock_id; + + #[Column] + public string $stock_name; + + #[Column(nullable: true)] + public ?string $note = null; + + #[HasMany(target: Value::class, foreignKey: 'stock_id')] + public iterable $values; + + #[ManyToMany(target: Category::class, through: 'sesha_inventory_categories')] + public iterable $categories; +} + +// Schema is now derived from the class: +$reader = new AttributeSchemaReader(); +$schema = $reader->read(Stock::class); +``` + +### Custom Mapper Logic + +Classic mappers sometimes override `delete()` or `create()` to handle +cascading operations. In the new style, this logic lives in a service class +or a subclass of `SqlRepository`: + +```php +// Classic: custom cascade delete in the mapper +class StockMapper extends Horde_Rdo_Mapper +{ + public function delete($object) + { + foreach ($object->values as $value) { + $value->delete(); + } + $object->removeRelation('categories'); + return parent::delete($object); + } +} + +// New-style: service class or repository subclass +class StockRepository extends SqlRepository +{ + public function delete(object $entity): void + { + // Clean up related values first + $valueRepo = $this->registry->getRepository(Value::class); + $values = $valueRepo->find(Field::equals('stock_id', $entity->stock_id)); + foreach ($values as $value) { + $valueRepo->delete($value); + } + + parent::delete($entity); + } +} +``` + +### Pace of Migration + +The classic and new-style classes coexist indefinitely. You can migrate one +mapper at a time, or even have classic and new-style code access the same +tables simultaneously. The path is: + +1. **Verbatim conversion** -- plain entity + TypeSchema + SqlRepository, + `FieldBagHydrator`, minimal calling-code changes. This is a mechanical + step that can be done for all mappers in a component at once. +2. **Typed entities** -- add type declarations, switch to `DefaultHydrator`. + Do this per-entity as you touch the code. +3. **New capabilities** -- composable criteria, relationship queries, + attribute schemas. Adopt these as you write new features or refactor + existing query logic. diff --git a/doc/migration-example.php b/doc/migration-example.php new file mode 100644 index 0000000..34034e3 --- /dev/null +++ b/doc/migration-example.php @@ -0,0 +1,265 @@ +#!/usr/bin/env php + ':memory:']); + +$adapter->execute(' + CREATE TABLE horde_users ( + user_uid VARCHAR(255) PRIMARY KEY, + user_pass VARCHAR(255) NOT NULL, + user_soft_expiration_date INTEGER, + user_hard_expiration_date INTEGER + ) +'); + +$adapter->execute("INSERT INTO horde_users VALUES ('alice', 'hash_alice', NULL, NULL)"); +$adapter->execute("INSERT INTO horde_users VALUES ('bob', 'hash_bob', 1700000000, 1800000000)"); +$adapter->execute("INSERT INTO horde_users VALUES ('carol', 'hash_carol', NULL, 1900000000)"); + +echo "=== Horde\\Rdo Migration Example ===\n"; +echo "Adapter: " . get_class($adapter) . "\n"; +echo " implements Horde_Db_Adapter: " . ($adapter instanceof Horde_Db_Adapter ? 'yes' : 'no') . "\n"; +echo " implements QuotingInterface: " . ($adapter instanceof Horde\Db\Query\QuotingInterface ? 'yes' : 'no') . "\n"; +echo "\n"; + +// ═══════════════════════════════════════════════════════════════════════ +// APPROACH 1: Legacy lib/ — Horde_Rdo_Mapper + Horde_Rdo_Base +// ═══════════════════════════════════════════════════════════════════════ +// +// This is the traditional Horde 5/6 API. Entity classes extend +// Horde_Rdo_Base, mapper classes extend Horde_Rdo_Mapper. Field access +// is dynamic (__get/__set), and queries use arrays or Horde_Rdo_Query. + +echo "--- Approach 1: Legacy lib/ (Horde_Rdo_Mapper) ---\n\n"; + +// Entity: just extend Horde_Rdo_Base — nothing else needed. +class LegacyUser extends Horde_Rdo_Base +{ +} + +// Mapper: set $_table to match the database table. +// The class name "LegacyUserMapper" auto-maps to entity "LegacyUser". +class LegacyUserMapper extends Horde_Rdo_Mapper +{ + protected $_table = 'horde_users'; + protected $_classname = LegacyUser::class; + protected $_setTimestamps = false; +} + +$legacyMapper = new LegacyUserMapper($adapter); + +// findOne by primary key +$user = $legacyMapper->findOne('alice'); +echo "findOne('alice'): uid={$user->user_uid}, pass={$user->user_pass}\n"; + +// find() returns Horde_Rdo_List (lazy iterator) +$all = $legacyMapper->find(); +echo "find() all users:\n"; +foreach ($all as $u) { + echo " uid={$u->user_uid}"; + if ($u->user_hard_expiration_date) { + echo ", hard_exp={$u->user_hard_expiration_date}"; + } + echo "\n"; +} + +// find with array criteria +$query = new Horde_Rdo_Query($legacyMapper); +$query->addTest('user_uid', '=', 'bob'); +$bob = $legacyMapper->findOne($query); +echo "findOne(query uid=bob): uid={$bob->user_uid}\n"; + +echo "\n"; + +// ═══════════════════════════════════════════════════════════════════════ +// APPROACH 2: src/ MapperSql — Drop-in replacement +// ═══════════════════════════════════════════════════════════════════════ +// +// MapperSql (Horde\Rdo\MapperSql) is a 1:1 replacement for +// Horde_Rdo_Mapper. The constructor, protected properties, and all +// public methods have the same signatures and behavior. Entities extend +// Horde\Rdo\Base instead of Horde_Rdo_Base. +// +// Migration: change two base classes, keep everything else identical. + +echo "--- Approach 2: src/ MapperSql (drop-in replacement) ---\n\n"; + +use Horde\Rdo\Base; +use Horde\Rdo\MapperSql; +use Horde\Rdo\Constants; + +// Entity: extend Horde\Rdo\Base instead of Horde_Rdo_Base. +class ModernUser extends Base +{ +} + +// Mapper: extend MapperSql instead of Horde_Rdo_Mapper. +// Same protected properties, same patterns. +class ModernUserMapper extends MapperSql +{ + protected $_table = 'horde_users'; + protected $_classname = ModernUser::class; + protected $_setTimestamps = false; +} + +$modernMapper = new ModernUserMapper($adapter); + +// findOne by primary key — identical API +$user = $modernMapper->findOne('alice'); +echo "findOne('alice'): uid={$user->user_uid}, pass={$user->user_pass}\n"; + +// find() — identical API, returns DefaultList +$all = $modernMapper->find(); +echo "find() all users:\n"; +foreach ($all as $u) { + echo " uid={$u->user_uid}"; + if ($u->user_hard_expiration_date) { + echo ", hard_exp={$u->user_hard_expiration_date}"; + } + echo "\n"; +} + +// ── Bonus: MapperSql also exposes the new Criterion API ── +// Because the adapter implements QuotingInterface, you can optionally +// use findByCriteria() for type-safe, composable queries. + +use Horde\Rdo\Field; +use Horde\Rdo\CriteriaBuilder; + +$results = $modernMapper->findByCriteria( + Field::equals('user_uid', 'bob') +); +echo "findByCriteria(uid=bob): uid={$results[0]->user_uid}\n"; + +// Composed criteria with builder +$criteria = CriteriaBuilder::from( + Field::isNotNull('user_hard_expiration_date') +)->limit(10); + +$expiring = $modernMapper->findByCriteria($criteria); +echo "findByCriteria(hard_exp IS NOT NULL): " . count($expiring) . " user(s)\n"; + +$count = $modernMapper->countByCriteria( + Field::isNotNull('user_hard_expiration_date') +); +echo "countByCriteria(hard_exp IS NOT NULL): {$count}\n"; + +// TypeSchema is available for introspection +$schema = $modernMapper->getTypeSchema(); +echo "TypeSchema table: {$schema->getTable()}, PK: {$schema->getPrimaryKey()->name}\n"; + +echo "\n"; + +// ═══════════════════════════════════════════════════════════════════════ +// APPROACH 3: New Model — TypeSchema + SqlRepository +// ═══════════════════════════════════════════════════════════════════════ +// +// The new model is fully decoupled from the Mapper pattern. You define +// a plain PHP entity class (no base class required), describe it with a +// TypeSchema, and use SqlRepository for persistence. Queries use the +// Criterion object model throughout. + +echo "--- Approach 3: New Model (TypeSchema + SqlRepository) ---\n\n"; + +use Horde\Rdo\TypeSchema; +use Horde\Rdo\FieldType; +use Horde\Rdo\SqlRepository; +use Horde\Rdo\DefaultHydrator; + +// Entity: plain PHP class — no base class, no magic, just typed properties. +class AuthUser +{ + public function __construct( + public string $user_uid = '', + public string $user_pass = '', + public ?int $user_soft_expiration_date = null, + public ?int $user_hard_expiration_date = null, + ) {} +} + +// Schema: declarative, fluent definition. +$userSchema = (new TypeSchema(AuthUser::class, 'horde_users')) + ->id('user_uid', FieldType::STRING) + ->field('user_pass', FieldType::STRING) + ->field('user_soft_expiration_date', FieldType::INT, nullable: true) + ->field('user_hard_expiration_date', FieldType::INT, nullable: true); + +// Repository: takes the adapter, schema, and a hydrator. +$hydrator = new DefaultHydrator(); +$repo = new SqlRepository($adapter, $userSchema, $hydrator); + +// findOne by primary key +$user = $repo->findOne('alice'); +echo "findOne('alice'): uid={$user->user_uid}, pass={$user->user_pass}\n"; + +// find with Criterion +$all = $repo->find(Field::greaterOrEqual('user_uid', '')); +echo "find(all): " . count($all) . " user(s)\n"; +foreach ($all as $u) { + echo " uid={$u->user_uid}"; + if ($u->user_hard_expiration_date) { + echo ", hard_exp={$u->user_hard_expiration_date}"; + } + echo "\n"; +} + +// Composed query +$criteria = CriteriaBuilder::from( + Field::isNotNull('user_hard_expiration_date') +)->orderBy('user_uid') + ->limit(10); + +$expiring = $repo->find($criteria); +echo "find(hard_exp IS NOT NULL): " . count($expiring) . " user(s)\n"; + +// count +$count = $repo->count(Field::isNotNull('user_hard_expiration_date')); +echo "count(hard_exp IS NOT NULL): {$count}\n"; + +// exists +echo "exists('alice'): " . ($repo->exists('alice') ? 'yes' : 'no') . "\n"; +echo "exists('nobody'): " . ($repo->exists('nobody') ? 'yes' : 'no') . "\n"; + +// save (insert) +$repo->save(new AuthUser('dave', 'hash_dave')); +echo "save(dave): inserted\n"; +echo "exists('dave'): " . ($repo->exists('dave') ? 'yes' : 'no') . "\n"; + +// delete +$repo->delete(new AuthUser('dave')); +echo "delete(dave): removed\n"; +echo "exists('dave'): " . ($repo->exists('dave') ? 'yes' : 'no') . "\n"; + +echo "\n=== Done ===\n"; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4c4861c..70abde0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,6 +16,7 @@ lib + src