Skip to content

Commit fe3db10

Browse files
wojteknJanJakes
andauthored
Add support for DEFAULT (expression) in column definitions (#306)
## Summary Adds full support for `DEFAULT (expression)` in `CREATE TABLE` statement column definitions. Fixes #300 - Support for `DEFAULT (now())`. ## Problem The MySQL 8.0 syntax `DEFAULT (function())` was failing because the translator only consumed a single token after the DEFAULT keyword. For expressions like `DEFAULT (now())`, it would only capture the opening parenthesis `(`, causing the query translation to fail. ## Solution 1. Full `DEFAULT (expression)` support in the AST driver. 2. Enhanced the `DEFAULT` clause handler in `WP_SQLite_Translator` to: - Detect when a DEFAULT value starts with an opening parenthesis - Track parenthesis depth to consume the complete expression - Handle nested parentheses correctly --------- Co-authored-by: Jan Jakes <jan@jakes.pro>
1 parent 0484b2c commit fe3db10

6 files changed

Lines changed: 305 additions & 30 deletions

tests/WP_SQLite_Driver_Metadata_Tests.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2176,43 +2176,43 @@ public function testInformationSchemaCheckConstraints(): void {
21762176
'CONSTRAINT_CATALOG' => 'def',
21772177
'CONSTRAINT_SCHEMA' => 'wp',
21782178
'CONSTRAINT_NAME' => 'c1',
2179-
'CHECK_CLAUSE' => '( id < 10 )',
2179+
'CHECK_CLAUSE' => 'id < 10',
21802180
),
21812181
(object) array(
21822182
'CONSTRAINT_CATALOG' => 'def',
21832183
'CONSTRAINT_SCHEMA' => 'wp',
21842184
'CONSTRAINT_NAME' => 'c2',
2185-
'CHECK_CLAUSE' => '( start_timestamp < end_timestamp )',
2185+
'CHECK_CLAUSE' => 'start_timestamp < end_timestamp',
21862186
),
21872187
(object) array(
21882188
'CONSTRAINT_CATALOG' => 'def',
21892189
'CONSTRAINT_SCHEMA' => 'wp',
21902190
'CONSTRAINT_NAME' => 'c3',
2191-
'CHECK_CLAUSE' => '( length ( data ) < 20 )',
2191+
'CHECK_CLAUSE' => 'length(data)< 20',
21922192
),
21932193
(object) array(
21942194
'CONSTRAINT_CATALOG' => 'def',
21952195
'CONSTRAINT_SCHEMA' => 'wp',
21962196
'CONSTRAINT_NAME' => 't_chk_1',
2197-
'CHECK_CLAUSE' => '( id > 0 )',
2197+
'CHECK_CLAUSE' => 'id > 0',
21982198
),
21992199
(object) array(
22002200
'CONSTRAINT_CATALOG' => 'def',
22012201
'CONSTRAINT_SCHEMA' => 'wp',
22022202
'CONSTRAINT_NAME' => 't_chk_2',
2203-
'CHECK_CLAUSE' => "( name != '' )",
2203+
'CHECK_CLAUSE' => "name != ''",
22042204
),
22052205
(object) array(
22062206
'CONSTRAINT_CATALOG' => 'def',
22072207
'CONSTRAINT_SCHEMA' => 'wp',
22082208
'CONSTRAINT_NAME' => 't_chk_3',
2209-
'CHECK_CLAUSE' => '( score > 0 AND score < 100 )',
2209+
'CHECK_CLAUSE' => 'score > 0 AND score < 100',
22102210
),
22112211
(object) array(
22122212
'CONSTRAINT_CATALOG' => 'def',
22132213
'CONSTRAINT_SCHEMA' => 'wp',
22142214
'CONSTRAINT_NAME' => 't_chk_4',
2215-
'CHECK_CLAUSE' => '( json_valid ( data ) )',
2215+
'CHECK_CLAUSE' => 'json_valid(data)',
22162216
),
22172217
),
22182218
$result
@@ -2262,13 +2262,13 @@ public function testInformationSchemaAlterTableAddCheckConstraint(): void {
22622262
'CONSTRAINT_CATALOG' => 'def',
22632263
'CONSTRAINT_SCHEMA' => 'wp',
22642264
'CONSTRAINT_NAME' => 'c',
2265-
'CHECK_CLAUSE' => '( id > 0 )',
2265+
'CHECK_CLAUSE' => 'id > 0',
22662266
),
22672267
(object) array(
22682268
'CONSTRAINT_CATALOG' => 'def',
22692269
'CONSTRAINT_SCHEMA' => 'wp',
22702270
'CONSTRAINT_NAME' => 't_chk_1',
2271-
'CHECK_CLAUSE' => '( id < 10 )',
2271+
'CHECK_CLAUSE' => 'id < 10',
22722272
),
22732273
),
22742274
$result

tests/WP_SQLite_Driver_Tests.php

Lines changed: 165 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9294,13 +9294,13 @@ public function testCheckConstraints(): void {
92949294

92959295
// The of the check expressions below is not 100% matching MySQL,
92969296
// because in MySQL the expressions are parsed and normalized.
9297-
' CONSTRAINT `c1` CHECK ( id < 10 ),',
9298-
' CONSTRAINT `c2` CHECK ( start_timestamp < end_timestamp ),',
9299-
' CONSTRAINT `c3` CHECK ( length ( data ) < 20 ),',
9300-
' CONSTRAINT `t_chk_1` CHECK ( id > 0 ),',
9301-
" CONSTRAINT `t_chk_2` CHECK ( name != '' ),",
9302-
' CONSTRAINT `t_chk_3` CHECK ( score > 0 AND score < 100 ),',
9303-
' CONSTRAINT `t_chk_4` CHECK ( json_valid ( data ) )',
9297+
' CONSTRAINT `c1` CHECK (id < 10),',
9298+
' CONSTRAINT `c2` CHECK (start_timestamp < end_timestamp),',
9299+
' CONSTRAINT `c3` CHECK (length(data)< 20),',
9300+
' CONSTRAINT `t_chk_1` CHECK (id > 0),',
9301+
" CONSTRAINT `t_chk_2` CHECK (name != ''),",
9302+
' CONSTRAINT `t_chk_3` CHECK (score > 0 AND score < 100),',
9303+
' CONSTRAINT `t_chk_4` CHECK (json_valid(data))',
93049304
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci',
93059305
)
93069306
),
@@ -9326,8 +9326,8 @@ public function testAlterTableAddCheckConstraint(): void {
93269326
array(
93279327
'CREATE TABLE `t` (',
93289328
' `id` int DEFAULT NULL,',
9329-
' CONSTRAINT `c` CHECK ( id > 0 ),',
9330-
' CONSTRAINT `t_chk_1` CHECK ( id < 10 )',
9329+
' CONSTRAINT `c` CHECK (id > 0),',
9330+
' CONSTRAINT `t_chk_1` CHECK (id < 10)',
93319331
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci',
93329332
)
93339333
),
@@ -9396,7 +9396,7 @@ public function testCheckConstraintNotEnforced(): void {
93969396
array(
93979397
'CREATE TABLE `t` (',
93989398
' `id` int DEFAULT NULL,',
9399-
' CONSTRAINT `c` CHECK ( id > 0 ) /*!80016 NOT ENFORCED */',
9399+
' CONSTRAINT `c` CHECK (id > 0) /*!80016 NOT ENFORCED */',
94009400
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci',
94019401
)
94029402
),
@@ -11368,4 +11368,159 @@ public function testSubstringFunction(): void {
1136811368
$result = $this->assertQuery( "SELECT SUBSTRING('abcdef' FROM 4) AS s" );
1136911369
$this->assertSame( 'def', $result[0]->s );
1137011370
}
11371+
11372+
/**
11373+
* Test CREATE TABLE with DEFAULT (now()) - GitHub issue #300
11374+
* Tests that DEFAULT with function calls in parentheses works correctly in AST driver.
11375+
*
11376+
* @see https://github.com/WordPress/sqlite-database-integration/issues/300
11377+
*/
11378+
public function testCreateTableWithDefaultNowFunction(): void {
11379+
// Test the exact SQL from the issue
11380+
$this->assertQuery(
11381+
'CREATE TABLE `test_now_default` (
11382+
`id` int NOT NULL,
11383+
`updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP
11384+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;'
11385+
);
11386+
11387+
// Insert a row to verify the default value works
11388+
$this->assertQuery( 'INSERT INTO test_now_default (id) VALUES (1)' );
11389+
$result = $this->assertQuery( 'SELECT * FROM test_now_default WHERE id = 1' );
11390+
$this->assertCount( 1, $result );
11391+
11392+
// Verify the updated timestamp was set (should match YYYY-MM-DD HH:MM:SS format)
11393+
$this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated );
11394+
11395+
// SHOW CREATE TABLE
11396+
$this->assertQuery( 'SHOW CREATE TABLE test_now_default' );
11397+
$results = $this->engine->get_query_results();
11398+
$this->assertEquals(
11399+
implode(
11400+
"\n",
11401+
array(
11402+
'CREATE TABLE `test_now_default` (',
11403+
' `id` int NOT NULL,',
11404+
' `updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP',
11405+
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci',
11406+
)
11407+
),
11408+
$results[0]->{'Create Table'}
11409+
);
11410+
11411+
// DESCRIBE
11412+
$this->assertQuery( 'DESCRIBE test_now_default' );
11413+
$results = $this->engine->get_query_results();
11414+
$this->assertEquals(
11415+
array(
11416+
(object) array(
11417+
'Field' => 'id',
11418+
'Type' => 'int',
11419+
'Null' => 'NO',
11420+
'Key' => '',
11421+
'Default' => null,
11422+
'Extra' => '',
11423+
),
11424+
(object) array(
11425+
'Field' => 'updated',
11426+
'Type' => 'timestamp',
11427+
'Null' => 'NO',
11428+
'Key' => '',
11429+
'Default' => 'now()',
11430+
'Extra' => 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP',
11431+
),
11432+
),
11433+
$results
11434+
);
11435+
11436+
// Verify the translated SQLite definition.
11437+
$result = $this->sqlite->query( 'PRAGMA table_info(test_now_default)' )->fetchAll();
11438+
$this->assertSame( null, $result[0]['dflt_value'] );
11439+
$this->assertSame( 'CURRENT_TIMESTAMP', $result[1]['dflt_value'] );
11440+
}
11441+
11442+
public function testCreateTableWithDefaultExpressions(): void {
11443+
$this->assertQuery(
11444+
'CREATE TABLE t (
11445+
id int NOT NULL,
11446+
col1 int NOT NULL DEFAULT (1 + 2),
11447+
col2 datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR)),
11448+
col3 varchar(255) NOT NULL DEFAULT (CONCAT(\'a\', \'b\'))
11449+
)'
11450+
);
11451+
11452+
// Insert a row and verify the default values
11453+
$this->assertQuery( 'INSERT INTO t (id) VALUES (1)' );
11454+
$this->assertQuery( 'SELECT * FROM t WHERE id = 1' );
11455+
$results = $this->engine->get_query_results();
11456+
$this->assertEquals( 3, $results[0]->col1 );
11457+
$this->assertStringStartsWith( ( gmdate( 'Y' ) + 1 ) . '-', $results[0]->col2 );
11458+
$this->assertEquals( 'ab', $results[0]->col3 );
11459+
11460+
// SHOW CREATE TABLE
11461+
$this->assertQuery( 'SHOW CREATE TABLE t' );
11462+
$results = $this->engine->get_query_results();
11463+
$this->assertEquals(
11464+
implode(
11465+
"\n",
11466+
array(
11467+
'CREATE TABLE `t` (',
11468+
' `id` int NOT NULL,',
11469+
' `col1` int NOT NULL DEFAULT (1 + 2),',
11470+
' `col2` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR)),',
11471+
" `col3` varchar(255) NOT NULL DEFAULT (CONCAT('a' , 'b'))",
11472+
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci',
11473+
)
11474+
),
11475+
$results[0]->{'Create Table'}
11476+
);
11477+
11478+
// DESCRIBE
11479+
$this->assertQuery( 'DESCRIBE t' );
11480+
$results = $this->engine->get_query_results();
11481+
$this->assertEquals(
11482+
array(
11483+
(object) array(
11484+
'Field' => 'id',
11485+
'Type' => 'int',
11486+
'Null' => 'NO',
11487+
'Key' => '',
11488+
'Default' => null,
11489+
'Extra' => '',
11490+
),
11491+
(object) array(
11492+
'Field' => 'col1',
11493+
'Type' => 'int',
11494+
'Null' => 'NO',
11495+
'Key' => '',
11496+
'Default' => '1 + 2',
11497+
'Extra' => 'DEFAULT_GENERATED',
11498+
),
11499+
(object) array(
11500+
'Field' => 'col2',
11501+
'Type' => 'datetime',
11502+
'Null' => 'NO',
11503+
'Key' => '',
11504+
'Default' => 'DATE_ADD(NOW(), INTERVAL 1 YEAR)',
11505+
'Extra' => 'DEFAULT_GENERATED',
11506+
),
11507+
(object) array(
11508+
'Field' => 'col3',
11509+
'Type' => 'varchar(255)',
11510+
'Null' => 'NO',
11511+
'Key' => '',
11512+
'Default' => "CONCAT('a' , 'b')",
11513+
'Extra' => 'DEFAULT_GENERATED',
11514+
),
11515+
),
11516+
$results
11517+
);
11518+
11519+
// Verify the translated SQLite definition.
11520+
$result = $this->sqlite->query( 'PRAGMA table_info(t)' )->fetchAll();
11521+
$this->assertSame( null, $result[0]['dflt_value'] );
11522+
$this->assertSame( '1 + 2', $result[1]['dflt_value'] );
11523+
$this->assertSame( "DATETIME(CURRENT_TIMESTAMP, '+' || 1 || ' YEAR')", $result[2]['dflt_value'] );
11524+
$this->assertSame( "('a' || 'b')", $result[3]['dflt_value'] );
11525+
}
1137111526
}

tests/WP_SQLite_Translator_Tests.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3520,4 +3520,41 @@ public static function mysqlVariablesToTest() {
35203520
array( '@@sEssIOn.sqL_moDe' ),
35213521
);
35223522
}
3523+
3524+
/**
3525+
* Test CREATE TABLE with DEFAULT (now()) - GitHub issue #300
3526+
* Tests that DEFAULT with function calls in parentheses works correctly.
3527+
*/
3528+
public function testCreateTableWithDefaultNowFunction() {
3529+
// Test the exact SQL from the issue
3530+
$this->assertQuery(
3531+
'CREATE TABLE `test_now_default` (
3532+
`id` int NOT NULL,
3533+
`updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP
3534+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;'
3535+
);
3536+
3537+
// Verify the table was created successfully
3538+
$results = $this->assertQuery( 'DESCRIBE test_now_default;' );
3539+
$this->assertCount( 2, $results );
3540+
3541+
// Verify the updated column has the correct properties
3542+
$updated_field = $results[1];
3543+
$this->assertEquals( 'updated', $updated_field->Field );
3544+
$this->assertEquals( 'timestamp', $updated_field->Type );
3545+
$this->assertEquals( 'NO', $updated_field->Null );
3546+
3547+
// Insert a row to verify the default value works
3548+
$this->assertQuery( 'INSERT INTO test_now_default (id) VALUES (1)' );
3549+
$result = $this->assertQuery( 'SELECT * FROM test_now_default WHERE id = 1' );
3550+
$this->assertCount( 1, $result );
3551+
3552+
// Verify the updated timestamp was set (should match YYYY-MM-DD HH:MM:SS format)
3553+
$this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated );
3554+
3555+
// Test ON UPDATE trigger works
3556+
$this->assertQuery( 'UPDATE test_now_default SET id = 2 WHERE id = 1' );
3557+
$result = $this->assertQuery( 'SELECT * FROM test_now_default WHERE id = 2' );
3558+
$this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated );
3559+
}
35233560
}

wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5908,15 +5908,20 @@ private function get_sqlite_create_table_statement(
59085908
$query .= ' PRIMARY KEY AUTOINCREMENT';
59095909
}
59105910
if ( null !== $column['COLUMN_DEFAULT'] ) {
5911-
// @TODO: Handle defaults with expression values (DEFAULT_GENERATED).
5912-
59135911
// Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp
59145912
// and datetime columns. For other column types, it's just a string.
59155913
if (
59165914
'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT']
59175915
&& ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] )
59185916
) {
59195917
$query .= ' DEFAULT CURRENT_TIMESTAMP';
5918+
} elseif ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) {
5919+
// Handle DEFAULT values with expressions (DEFAULT_GENERATED).
5920+
// Translate the default clause from MySQL to SQLite.
5921+
$ast = $this->create_parser( 'SELECT ' . $column['COLUMN_DEFAULT'] )->parse();
5922+
$expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node();
5923+
$default_clause = $this->translate( $expr );
5924+
$query .= sprintf( ' DEFAULT (%s)', $default_clause );
59205925
} else {
59215926
$query .= ' DEFAULT ' . $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] );
59225927
}
@@ -6044,7 +6049,7 @@ function ( $column ) {
60446049
$check_clause = $this->translate( $expr );
60456050

60466051
$sql = sprintf(
6047-
' CONSTRAINT %s CHECK %s',
6052+
' CONSTRAINT %s CHECK (%s)',
60486053
$this->quote_sqlite_identifier( $check_constraint['CONSTRAINT_NAME'] ),
60496054
$check_clause
60506055
);
@@ -6220,7 +6225,11 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str
62206225
) {
62216226
$sql .= ' DEFAULT CURRENT_TIMESTAMP';
62226227
} elseif ( null !== $column['COLUMN_DEFAULT'] ) {
6223-
$sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] );
6228+
if ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) {
6229+
$sql .= sprintf( ' DEFAULT (%s)', $column['COLUMN_DEFAULT'] );
6230+
} else {
6231+
$sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] );
6232+
}
62246233
} elseif ( 'YES' === $column['IS_NULLABLE'] ) {
62256234
$sql .= ' DEFAULT NULL';
62266235
}
@@ -6324,7 +6333,7 @@ function ( $column ) {
63246333
// 9. Add CHECK constraints.
63256334
foreach ( $check_constraints_info as $check_constraint ) {
63266335
$sql = sprintf(
6327-
' CONSTRAINT %s CHECK %s%s',
6336+
' CONSTRAINT %s CHECK (%s)%s',
63286337
$this->quote_mysql_identifier( $check_constraint['CONSTRAINT_NAME'] ),
63296338
$check_constraint['CHECK_CLAUSE'],
63306339
'NO' === $check_constraint['ENFORCED'] ? ' /*!80016 NOT ENFORCED */' : ''

0 commit comments

Comments
 (0)