1
<?php
2

3
namespace Nuwave\Lighthouse\WhereConditions;
4

5
use GraphQL\Error\Error;
6
use GraphQL\Language\AST\FieldDefinitionNode;
7
use GraphQL\Language\AST\InputValueDefinitionNode;
8
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
9
use GraphQL\Language\Parser;
10
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
11
use Illuminate\Database\Eloquent\Model;
12
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
13
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
14
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
15
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
16
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
17
use Nuwave\Lighthouse\Support\Traits\GeneratesColumnsEnum;
18

19
abstract class WhereConditionsBaseDirective extends BaseDirective implements ArgBuilderDirective, ArgManipulator
20
{
21
    use GeneratesColumnsEnum;
22

23
    /**
24
     * @var \Nuwave\Lighthouse\WhereConditions\Operator
25
     */
26
    protected $operator;
27

28 1
    public function __construct(Operator $operator)
29
    {
30 1
        $this->operator = $operator;
31
    }
32

33
    /**
34
     * @param  \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder  $builder
35
     * @param  array<string, mixed>  $whereConditions
36
     * @return \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder
37
     */
38 1
    public function handleWhereConditions(
39
        object $builder,
40
        array $whereConditions,
41
        Model $model = null,
42
        string $boolean = 'and'
43
    ) {
44 1
        if ($builder instanceof EloquentBuilder) {
45 1
            $model = $builder->getModel();
46
        }
47

48 1
        if ($andConnectedConditions = $whereConditions['AND'] ?? null) {
49 1
            $builder->whereNested(
50
                function ($builder) use ($andConnectedConditions, $model): void {
51 1
                    foreach ($andConnectedConditions as $condition) {
52 1
                        $this->handleWhereConditions($builder, $condition, $model);
53
                    }
54 1
                },
55
                $boolean
56
            );
57
        }
58

59 1
        if ($orConnectedConditions = $whereConditions['OR'] ?? null) {
60 1
            $builder->whereNested(
61
                function ($builder) use ($orConnectedConditions, $model): void {
62 1
                    foreach ($orConnectedConditions as $condition) {
63 1
                        $this->handleWhereConditions($builder, $condition, $model, 'or');
64
                    }
65 1
                },
66
                $boolean
67
            );
68
        }
69

70 1
        if (($hasRelationConditions = $whereConditions['HAS'] ?? null) && $model) {
71 1
            $this->handleHasCondition(
72 1
                $builder,
73
                $model,
74 1
                $hasRelationConditions['relation'],
75 1
                $hasRelationConditions['condition'] ?? null,
76 1
                $hasRelationConditions['amount'] ?? null,
77 1
                $hasRelationConditions['operator'] ?? null
78
            );
79
        }
80

81 1
        if ($column = $whereConditions['column'] ?? null) {
82 1
            static::assertValidColumnReference($column);
83

84 1
            return $this->operator->applyConditions($builder, $whereConditions, $boolean);
85
        }
86

87 1
        return $builder;
88
    }
89

90
    /**
91
     * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
92
     * @param array<string, mixed>|null $condition
93
     */
94 1
    public function handleHasCondition(
95
        object $builder,
96
        Model $model,
97
        string $relation,
98
        ?array $condition = null,
99
        ?int $amount = null,
100
        ?string $operator = null
101
    ): void {
102 1
        $additionalArguments = [];
103

104 1
        if ($operator !== null) {
105 1
            $additionalArguments[] = $operator;
106
        }
107

108 1
        if ($amount !== null) {
109 1
            $additionalArguments[] = $amount;
110
        }
111

112
        /** @var \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> $whereHasQuery */
113
        $whereHasQuery = $model
114 1
            ->whereHas(
115
                $relation,
116
                function ($builder) use ($relation, $model, $condition): void {
117 1
                    if ($condition) {
118 1
                        $relatedModel = $this->nestedRelatedModel($model, $relation);
119

120 1
                        $this->handleWhereConditions(
121 1
                            $builder,
122 1
                            $this->prefixConditionWithTableName(
123 1
                                $condition,
124
                                $relatedModel
125
                            ),
126
                            $relatedModel
127
                        );
128
                    }
129 1
                },
130 1
                ...$additionalArguments
131
            )
132 1
            ->getQuery();
133

134 1
        $builder->addNestedWhereQuery($whereHasQuery);
135
    }
136

137 1
    public static function invalidColumnName(string $column): string
138
    {
139 1
        return "Column names may contain only alphanumerics or underscores, and may not begin with a digit, got: $column";
140
    }
141

142 1
    public function manipulateArgDefinition(
143
        DocumentAST &$documentAST,
144
        InputValueDefinitionNode &$argDefinition,
145
        FieldDefinitionNode &$parentField,
146
        ObjectTypeDefinitionNode &$parentType
147
    ): void {
148 1
        if ($this->hasAllowedColumns()) {
149 1
            $restrictedWhereConditionsName = ASTHelper::qualifiedArgType($argDefinition, $parentField, $parentType).$this->generatedInputSuffix();
150 1
            $argDefinition->type = Parser::namedType($restrictedWhereConditionsName);
151 1
            $allowedColumnsEnumName = $this->generateColumnsEnum($documentAST, $argDefinition, $parentField, $parentType);
152

153
            $documentAST
154 1
                ->setTypeDefinition(
155 1
                    WhereConditionsServiceProvider::createWhereConditionsInputType(
156 1
                        $restrictedWhereConditionsName,
157 1
                        "Dynamic WHERE conditions for the `{$argDefinition->name->value}` argument on the query `{$parentField->name->value}`.",
158 1
                        $allowedColumnsEnumName
159
                    )
160
                )
161 1
                ->setTypeDefinition(
162 1
                    WhereConditionsServiceProvider::createHasConditionsInputType(
163 1
                        $restrictedWhereConditionsName,
164 1
                        "Dynamic HAS conditions for WHERE conditions for the `{$argDefinition->name->value}` argument on the query `{$parentField->name->value}`."
165
                    )
166
                );
167
        } else {
168 1
            $argDefinition->type = Parser::namedType(WhereConditionsServiceProvider::DEFAULT_WHERE_CONDITIONS);
169
        }
170
    }
171

172
    /**
173
     * Ensure the column name is well formed to prevent SQL injection.
174
     *
175
     * @throws \GraphQL\Error\Error
176
     */
177 1
    protected static function assertValidColumnReference(string $column): void
178
    {
179
        // A valid column reference:
180
        // - must not start with a digit, dot or hyphen
181
        // - must contain only alphanumerics, digits, underscores, dots or hyphens
182
        // Dots are allowed to reference a column in a table: my_table.my_column.
183 1
        $match = \Safe\preg_match('/^(?![0-9.-])[A-Za-z0-9_.-]*$/', $column);
184 1
        if ($match === 0) {
185 1
            throw new Error(
186 1
                self::invalidColumnName($column)
187
            );
188
        }
189
    }
190

191 1
    protected function nestedRelatedModel(Model $model, string $nestedRelationPath): Model
192
    {
193 1
        $relations = explode('.', $nestedRelationPath);
194 1
        $relatedModel = $model->newInstance();
195

196 1
        foreach ($relations as $relation) {
197 1
            $relatedModel = $relatedModel->{$relation}()->getRelated();
198
        }
199

200 1
        return $relatedModel;
201
    }
202

203
    /**
204
     * If the condition references a column, prefix it with the table name.
205
     *
206
     * This is important for queries which can otherwise be ambiguous, for
207
     * example when multiple tables with a column "id" are involved.
208
     *
209
     * @param  array<string, mixed>  $condition
210
     * @return array<string, mixed>
211
     */
212 1
    protected function prefixConditionWithTableName(array $condition, Model $model): array
213
    {
214 1
        if ($column = $condition['column'] ?? null) {
215 1
            $condition['column'] = $model->getTable().'.'.$column;
216
        }
217

218 1
        return $condition;
219
    }
220

221
    /**
222
     * Get the suffix that will be added to generated input types.
223
     */
224
    abstract protected function generatedInputSuffix(): string;
225
}

Read our documentation on viewing source code .

Loading