Skip to content

Commit 37f29a9

Browse files
authored
Merge pull request #280 from kitloong/feature/enum
Fix #276 Parse enum constraint correctly
2 parents f592594 + 0b99e76 commit 37f29a9

File tree

3 files changed

+177
-8
lines changed

3 files changed

+177
-8
lines changed

src/Database/Models/PgSQL/PgSQLColumn.php

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use KitLoong\MigrationsGenerator\Database\Models\DatabaseColumn;
77
use KitLoong\MigrationsGenerator\Enum\Migrations\Method\ColumnType;
88
use KitLoong\MigrationsGenerator\Repositories\PgSQLRepository;
9-
use KitLoong\MigrationsGenerator\Support\Regex;
109

1110
class PgSQLColumn extends DatabaseColumn
1211
{
@@ -163,20 +162,56 @@ private function getEnumPresetValues(): array
163162
{
164163
$definition = $this->repository->getCheckConstraintDefinition($this->tableName, $this->name);
165164

166-
if ($definition === null) {
165+
if ($definition === null || $definition === '') {
167166
return [];
168167
}
169168

170-
if ($definition === '') {
171-
return [];
169+
$enumValues = $this->parseEnumValuesFromConstraint($definition);
170+
171+
if (count($enumValues) > 0) {
172+
return array_values(array_unique($enumValues));
172173
}
173174

174-
$presetValues = Regex::getTextBetweenAll($definition, "'", "'::");
175+
return [];
176+
}
175177

176-
if ($presetValues === null) {
177-
return [];
178+
/**
179+
* This method handles various PostgreSQL check constraint patterns:
180+
*
181+
* 1. ANY ARRAY: CHECK ((status)::text = ANY ((ARRAY['active'::character varying, 'inactive'::character varying])::text[]))
182+
* 2. OR conditions: CHECK (((status)::text = 'active'::text) OR ((status)::text = 'inactive'::text))
183+
* 3. IN clause: CHECK (status IN ('active', 'inactive', 'pending'))
184+
*
185+
* @return string[]
186+
*/
187+
private function parseEnumValuesFromConstraint(string $definition): array
188+
{
189+
// Pattern 1: ANY with ARRAY pattern (most common in PostgreSQL)
190+
// Example: CHECK ((status)::text = ANY ((ARRAY['active'::character varying, 'inactive'::character varying])::text[]))
191+
if (preg_match('/ARRAY\[(.*?)\]/i', $definition, $matches)) {
192+
$arrayContent = $matches[1];
193+
194+
if (preg_match_all('/\'([^\']+)\'/i', $arrayContent, $valueMatches)) {
195+
return $valueMatches[1];
196+
}
197+
}
198+
199+
// Pattern 2: Multiple OR conditions
200+
// Example: CHECK (((status)::text = 'active'::text) OR ((status)::text = 'inactive'::text))
201+
if (preg_match_all('/\(\(' . preg_quote($this->name, '/') . '\)[^=]*=\s*\'([^\']+)\'/i', $definition, $matches)) {
202+
return array_unique($matches[1]);
203+
}
204+
205+
// Pattern 3: Simple IN clause
206+
// Example: CHECK (status IN ('active', 'inactive', 'pending'))
207+
if (preg_match('/' . preg_quote($this->name, '/') . '\s+IN\s*\(\s*(.*?)\s*\)/i', $definition, $matches)) {
208+
$inContent = $matches[1];
209+
210+
if (preg_match_all('/\'([^\']+)\'/i', $inContent, $valueMatches)) {
211+
return $valueMatches[1];
212+
}
178213
}
179214

180-
return $presetValues;
215+
return [];
181216
}
182217
}

tests/Unit/Database/Models/PgSQL/PgSQLColumnTest.php

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use KitLoong\MigrationsGenerator\Tests\TestCase;
99
use Mockery\MockInterface;
1010
use PHPUnit\Framework\Attributes\DataProvider;
11+
use ReflectionClass;
1112

1213
class PgSQLColumnTest extends TestCase
1314
{
@@ -35,6 +36,38 @@ public function testSpatialTypeName(string $type): void
3536
$this->assertSame(4326, $column->getSpatialSrID());
3637
}
3738

39+
/**
40+
* @param string[] $expectedValues
41+
*/
42+
#[DataProvider('parseEnumValuesFromConstraintProvider')]
43+
public function testParseEnumValuesFromConstraint(string $constraintDefinition, array $expectedValues): void
44+
{
45+
$this->mock(PgSQLRepository::class, static function (MockInterface $mock): void {
46+
$mock->shouldReceive('getCheckConstraintDefinition')->andReturn('');
47+
});
48+
49+
$column = new PgSQLColumn('test_table', [
50+
'name' => 'status',
51+
'type_name' => 'varchar',
52+
'type' => 'character varying(255)',
53+
'collation' => null,
54+
'nullable' => false,
55+
'default' => null,
56+
'auto_increment' => false,
57+
'comment' => null,
58+
'generation' => null,
59+
]);
60+
61+
// Use reflection to access the private method
62+
$reflection = new ReflectionClass($column);
63+
$method = $reflection->getMethod('parseEnumValuesFromConstraint');
64+
$method->setAccessible(true);
65+
66+
$result = $method->invoke($column, $constraintDefinition);
67+
68+
$this->assertSame($expectedValues, $result);
69+
}
70+
3871
/**
3972
* @return array<string, string[]>
4073
*/
@@ -45,4 +78,104 @@ public static function spatialTypeNameProvider(): array
4578
'without dot' => ['geography(Point,4326)'],
4679
];
4780
}
81+
82+
/**
83+
* @return array<string, array{string, string[]}>
84+
*/
85+
public static function parseEnumValuesFromConstraintProvider(): array
86+
{
87+
return [
88+
// Pattern 1: ANY with ARRAY pattern (most common in PostgreSQL)
89+
'ANY ARRAY with character varying' => [
90+
"CHECK ((status)::text = ANY ((ARRAY['active'::character varying, 'inactive'::character varying, 'pending'::character varying])::text[]))",
91+
['active', 'inactive', 'pending'],
92+
],
93+
'ANY ARRAY with text' => [
94+
"CHECK ((status)::text = ANY ((ARRAY['draft'::text, 'published'::text])::text[]))",
95+
['draft', 'published'],
96+
],
97+
'ANY ARRAY with mixed types' => [
98+
"CHECK ((priority)::text = ANY ((ARRAY['low'::character varying, 'medium'::text, 'high'::character varying])::text[]))",
99+
['low', 'medium', 'high'],
100+
],
101+
'ANY ARRAY case insensitive' => [
102+
"check ((status)::text = any ((array['Active'::character varying, 'INACTIVE'::character varying])::text[]))",
103+
['Active', 'INACTIVE'],
104+
],
105+
'ANY ARRAY with extra spaces' => [
106+
"CHECK ( ( status )::text = ANY ( ( ARRAY[ 'option1'::character varying , 'option2'::character varying ] )::text[] ) )",
107+
['option1', 'option2'],
108+
],
109+
110+
// Pattern 2: Multiple OR conditions
111+
'OR conditions basic' => [
112+
"CHECK (((status)::text = 'active'::text) OR ((status)::text = 'inactive'::text))",
113+
['active', 'inactive'],
114+
],
115+
'OR conditions with more values' => [
116+
"CHECK (((status)::text = 'draft'::text) OR ((status)::text = 'review'::text) OR ((status)::text = 'published'::text))",
117+
['draft', 'review', 'published'],
118+
],
119+
'OR conditions case insensitive' => [
120+
"check (((status)::text = 'ACTIVE'::text) or ((status)::text = 'inactive'::text))",
121+
['ACTIVE', 'inactive'],
122+
],
123+
'OR conditions with character varying' => [
124+
"CHECK (((status)::character varying = 'yes'::character varying) OR ((status)::character varying = 'no'::character varying))",
125+
['yes', 'no'],
126+
],
127+
'OR conditions with duplicates' => [
128+
"CHECK (((status)::text = 'active'::text) OR ((status)::text = 'inactive'::text) OR ((status)::text = 'active'::text))",
129+
['active', 'inactive'], // Should remove duplicates
130+
],
131+
132+
// Pattern 3: Simple IN clause
133+
'IN clause basic' => [
134+
"CHECK (status IN ('active', 'inactive', 'pending'))",
135+
['active', 'inactive', 'pending'],
136+
],
137+
'IN clause case insensitive' => [
138+
"check (status in ('YES', 'no', 'Maybe'))",
139+
['YES', 'no', 'Maybe'],
140+
],
141+
'IN clause with extra spaces' => [
142+
"CHECK ( status IN ( 'option1' , 'option2' , 'option3' ) )",
143+
['option1', 'option2', 'option3'],
144+
],
145+
'IN clause single value' => [
146+
"CHECK (status IN ('single'))",
147+
['single'],
148+
],
149+
150+
// Edge cases and invalid patterns
151+
"CHECK (((string IS NULL) OR ((string)::text ~ '^O\.[0-9]+$'::text)))" => [
152+
'',
153+
[],
154+
],
155+
'empty constraint' => [
156+
'',
157+
[],
158+
],
159+
'non-enum constraint' => [
160+
"CHECK (age > 18)",
161+
[],
162+
],
163+
'malformed ARRAY pattern' => [
164+
"CHECK ((status)::text = ANY (ARRAY[missing quotes]))",
165+
[],
166+
],
167+
'different column name in OR' => [
168+
"CHECK (((other_column)::text = 'value1'::text) OR ((other_column)::text = 'value2'::text))",
169+
[],
170+
],
171+
'different column name in IN' => [
172+
"CHECK (other_column IN ('value1', 'value2'))",
173+
[],
174+
],
175+
'no values in ARRAY' => [
176+
"CHECK ((status)::text = ANY ((ARRAY[])::text[]))",
177+
[],
178+
],
179+
];
180+
}
48181
}

tests/resources/database/migrations/general/2020_03_21_000000_expected_create_all_columns_table.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public function up()
5656
$table->double('double_default')->default(10.8);
5757
$table->enum('enum', ['easy', 'hard']);
5858
$table->enum('enum_default', ['easy', 'hard'])->default('easy');
59+
$table->enum('enum_special', ['IN', 'ANY', 'OR', 'BETWEEN', 'value::character']);
5960
$table->float('float');
6061
$table->float('float_default')->default(10.8);
6162
$table->integer('integer');

0 commit comments

Comments
 (0)