├── composer.json └── src ├── Facades └── Jql.php ├── Field.php ├── Jql.php ├── Keyword.php └── Operator.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devmoath/jql-builder", 3 | "description": "JQL builder is a supercharged PHP package that allows you to create Jira Query Language (JQL)", 4 | "keywords": [ 5 | "php", 6 | "jira", 7 | "sdk", 8 | "jql", 9 | "builder" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Moath Alhajri", 15 | "email": "moath.alhajrii@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0", 20 | "spatie/macroable": "^2.0" 21 | }, 22 | "require-dev": { 23 | "laravel/pint": "^1.2.0", 24 | "nunomaduro/collision": "^6.0", 25 | "orchestra/testbench": "^7.0", 26 | "pestphp/pest": "^1.0", 27 | "pestphp/pest-plugin-laravel": "^1.3", 28 | "phpstan/extension-installer": "^1.2", 29 | "phpstan/phpstan": "^1.8.6", 30 | "phpstan/phpstan-strict-rules": "^1.4", 31 | "symfony/var-dumper": "^6.0.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "JqlBuilder\\": "src" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Tests\\": "tests" 41 | } 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true, 45 | "config": { 46 | "sort-packages": true, 47 | "preferred-install": "dist", 48 | "allow-plugins": { 49 | "pestphp/pest-plugin": true, 50 | "phpstan/extension-installer": true 51 | } 52 | }, 53 | "extra": { 54 | "laravel": { 55 | "aliases": { 56 | "Jql": "JqlBuilder\\Facades\\Jql" 57 | } 58 | } 59 | }, 60 | "scripts": { 61 | "lint": "pint --preset laravel -v --ansi", 62 | "test:lint": "pint --preset laravel --test -v --ansi", 63 | "test:types": "phpstan analyse --ansi", 64 | "test:unit": "pest --colors=always --min=100 --order-by=random --coverage", 65 | "test": [ 66 | "@test:lint", 67 | "@test:types", 68 | "@test:unit" 69 | ] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Facades/Jql.php: -------------------------------------------------------------------------------- 1 | getQuery() === '') { 26 | $queryTemplate = '(%s)'; 27 | } else { 28 | $queryTemplate = "{$this->getQuery()} $boolean (%s)"; 29 | $this->query = ''; 30 | } 31 | 32 | $column($this); 33 | 34 | $this->query = sprintf($queryTemplate, $this->getQuery()); 35 | 36 | return $this; 37 | } 38 | 39 | if (func_num_args() === 2) { 40 | [$column, $operator, $value] = [$column, is_array($operator) ? Operator::IN : Operator::EQUALS, $operator]; 41 | } 42 | 43 | /** @var string $operator */ 44 | $this->invalidBooleanOrOperator($boolean, $operator, $value); 45 | 46 | $this->appendQuery("{$this->escapeSpaces($column)} $operator {$this->quote($operator, $value)}", $boolean); 47 | 48 | return $this; 49 | } 50 | 51 | public function orWhere(string|Closure $column, mixed $operator = Operator::EQUALS, mixed $value = null): self 52 | { 53 | if (func_num_args() === 2) { 54 | [$column, $operator, $value] = [$column, is_array($operator) ? Operator::IN : Operator::EQUALS, $operator]; 55 | } 56 | 57 | $this->where($column, $operator, $value, Keyword::OR); 58 | 59 | return $this; 60 | } 61 | 62 | public function when(mixed $value, callable $callback): self 63 | { 64 | $value = $value instanceof Closure ? $value($this) : $value; 65 | 66 | if ($value) { 67 | return $callback($this, $value) ?? $this; 68 | } 69 | 70 | return $this; 71 | } 72 | 73 | public function whenNot(mixed $value, callable $callback): self 74 | { 75 | $value = $value instanceof Closure ? $value($this) : $value; 76 | 77 | if (! $value) { 78 | return $callback($this, $value) ?? $this; 79 | } 80 | 81 | return $this; 82 | } 83 | 84 | public function orderBy(string $column, string $direction): self 85 | { 86 | $this->appendQuery(Keyword::ORDER_BY." {$this->escapeSpaces($column)} $direction"); 87 | 88 | return $this; 89 | } 90 | 91 | public function rawQuery(string $query): self 92 | { 93 | $this->appendQuery($query); 94 | 95 | return $this; 96 | } 97 | 98 | public function reset(): self 99 | { 100 | $this->query = ''; 101 | 102 | return $this; 103 | } 104 | 105 | public function getQuery(): string 106 | { 107 | return trim($this->query); 108 | } 109 | 110 | public function __toString(): string 111 | { 112 | return $this->getQuery(); 113 | } 114 | 115 | private function escapeSpaces(string $column): string 116 | { 117 | if (! str_contains($column, ' ')) { 118 | return $column; 119 | } 120 | 121 | return "\"$column\""; 122 | } 123 | 124 | private function quote(string $operator, mixed $value): string 125 | { 126 | if (in_array($operator, [Operator::IN, Operator::NOT_IN, Operator::WAS_IN, Operator::WAS_NOT_IN], true)) { 127 | $values = array_reduce( 128 | is_array($value) ? $value : [$value], 129 | function ($prev, $current): string { 130 | if ($prev === null) { 131 | return '"'.str_replace('"', '\\"', $current).'"'; 132 | } 133 | 134 | return $prev.', "'.str_replace('"', '\\"', $current).'"'; 135 | } 136 | ); 137 | 138 | return "($values)"; 139 | } 140 | 141 | /** @var string|int $value */ 142 | $escapedValue = str_replace('"', '\\"', (string) $value); 143 | 144 | return "\"$escapedValue\""; 145 | } 146 | 147 | private function appendQuery(string $query, string $boolean = ''): void 148 | { 149 | if ($this->getQuery() === '') { 150 | $this->query = $query; 151 | } else { 152 | $this->query .= ' '.trim("$boolean $query"); 153 | } 154 | } 155 | 156 | /** 157 | * @throws InvalidArgumentException 158 | */ 159 | private function invalidBooleanOrOperator(string $boolean, string $operator, mixed $value): void 160 | { 161 | if (! in_array($boolean, Keyword::booleans(), true)) { 162 | throw new InvalidArgumentException(sprintf( 163 | 'Illegal boolean [%s] value. only [%s, %s] is acceptable', 164 | $boolean, 165 | ...Keyword::booleans(), 166 | )); 167 | } 168 | 169 | if (is_array($value) && ! in_array($operator, Operator::acceptList(), true)) { 170 | throw new InvalidArgumentException(sprintf( 171 | 'Illegal operator [%s] value. only [%s, %s, %s, %s] is acceptable when $value type is array', 172 | $operator, 173 | ...Operator::acceptList(), 174 | )); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Keyword.php: -------------------------------------------------------------------------------- 1 | '; 15 | 16 | public const GREATER_THAN_EQUALS = '>='; 17 | 18 | public const LESS_THAN = '<'; 19 | 20 | public const LESS_THAN_EQUALS = '<='; 21 | 22 | public const IN = 'in'; 23 | 24 | public const NOT_IN = 'not in'; 25 | 26 | public const CONTAINS = '~'; 27 | 28 | public const DOES_NOT_CONTAIN = '!~'; 29 | 30 | public const IS = 'is'; 31 | 32 | public const IS_NOT = 'is not'; 33 | 34 | public const WAS = 'was'; 35 | 36 | public const WAS_IN = 'was in'; 37 | 38 | public const WAS_NOT_IN = 'was not in'; 39 | 40 | public const WAS_NOT = 'was not'; 41 | 42 | public const CHANGED = 'changed'; 43 | 44 | /** 45 | * @return string[] 46 | */ 47 | public static function acceptList(): array 48 | { 49 | return [ 50 | self::IN, 51 | self::NOT_IN, 52 | self::WAS_IN, 53 | self::WAS_NOT_IN, 54 | ]; 55 | } 56 | } 57 | --------------------------------------------------------------------------------