Skip to content

Commit b6ba7a1

Browse files
authored
Merge pull request #1933 from Wieter/development-postgres
Initial PostgreSQL support
2 parents 2572c4e + 0d0f50d commit b6ba7a1

File tree

15 files changed

+157
-32
lines changed

15 files changed

+157
-32
lines changed

.env.dist

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ DATABASE_URL=sqlite:///%kernel.project_dir%/var/data/bolt.sqlite
2424
#DATABASE_URL=mysql://db_user:"db_password"@localhost:3306/db_name
2525

2626
# Postgres
27-
#DATABASE_URL=postgresql://db_user:"db_password"@localhost:5432/db_name?serverVersion=11"
27+
#DATABASE_URL=postgresql://db_user:"db_password"@localhost:5432/db_name?serverVersion=11&charset=utf8"
2828

2929
# MYSQL / MariaDB (additional settings, needed for Docker)
3030
#DATABASE_USER=db_user

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,7 @@ appveyor.yml
9292
###> phpunit/phpunit ###
9393
/phpunit.xml
9494
###< phpunit/phpunit ###
95+
96+
###> .preload dev ###
97+
/src/.preload.php
98+
###< .preload dev ###

README.md

+13-12
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,19 @@ bin/console doctrine:fixtures:load -n
117117

118118
Alternatively, run `make db-reset`, on a UNIX-like system.
119119

120-
4 Run the prototype
120+
4 How to build assets
121+
-------------------
122+
123+
To set up initially, run `npm install` to get the required dependencies /
124+
`node_modules`. Alternatively give the path to the python executable (`npm install --python="/usr/local/bin/python3.7"`) Then:
125+
- Prepare directory structure `mkdir -p node_modules/node-sass/vendor`
126+
- Rebuild npm environment for current OS `npm rebuild node-sass`
127+
- Run `npm run start` (alternatively `npm run start --python="/usr/local/bin/python3.7"`)
128+
129+
See the other options by running `npm run`.
130+
(Note: as I'm testing this as well remotely, I copied all assets from the released composer installation by `cp -r ../www_backup/public/assets/* public/assets/`)
131+
132+
5 Run the prototype
121133
-------------------
122134

123135
- Using the Symfony CLI tool, just run `symfony server:start`.
@@ -134,17 +146,6 @@ You can log on, using the default user & pass:
134146
- pass: `admin%1`
135147

136148

137-
How to build assets
138-
-------------------
139-
140-
To set up initially, run `npm install` to get the required dependencies /
141-
`node_modules`. Then:
142-
143-
- Run `npm run start`
144-
145-
See the other options by running `npm run`.
146-
147-
148149
Code Style checking / Static Analysis
149150
----------------------------
150151

config/packages/doctrine.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ doctrine:
3232
dql:
3333
string_functions:
3434
JSON_EXTRACT: Bolt\Doctrine\Functions\JsonExtract
35-
CAST: DoctrineExtensions\Query\Mysql\Cast
35+
JSON_GET_TEXT: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonGetText
36+
CAST: Bolt\Doctrine\Query\Cast
3637
numeric_functions:
3738
RAND: Bolt\Doctrine\Functions\Rand
3839

src/Doctrine/Functions/Rand.php

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker)
1919
if (property_exists($this->expression, 'value') && $this->expression->value === '1') {
2020
return 'random()';
2121
}
22+
// value is two if PostgreSQL. See Bolt\Storage\Directive\RandomDirectiveHandler
23+
if (property_exists($this->expression, 'value') && $this->expression->value === '2') {
24+
return 'RANDOM()';
25+
}
2226

2327
return 'RAND()';
2428
}

src/Doctrine/JsonHelper.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ public static function wrapJsonFunction(?string $where = null, ?string $slug = n
2525
$version = new Version($connection);
2626

2727
if ($version->hasJson()) {
28-
$resultWhere = 'JSON_EXTRACT(' . $where . ", '$[0]')";
28+
//PostgreSQL handles JSON differently than MySQL
29+
if ($version->getPlatform()['driver_name'] === 'pgsql') {
30+
// PostgreSQL
31+
$resultWhere = 'JSON_GET_TEXT(' . $where . ', 0)';
32+
} else {
33+
// MySQL and SQLite
34+
$resultWhere = 'JSON_EXTRACT(' . $where . ", '$[0]')";
35+
}
2936
$resultSlug = $slug;
3037
} else {
3138
$resultWhere = $where;

src/Doctrine/Query/Cast.php

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bolt\Doctrine\Query;
6+
7+
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
8+
use Doctrine\ORM\Query\Lexer;
9+
use Doctrine\ORM\Query\Parser;
10+
use Doctrine\ORM\Query\SqlWalker;
11+
12+
class Cast extends FunctionNode
13+
{
14+
/** @var \Doctrine\ORM\Query\AST\PathExpression */
15+
protected $first;
16+
/** @var string */
17+
protected $second;
18+
/** @var string */
19+
protected $backend_driver;
20+
21+
public function getSql(SqlWalker $sqlWalker): string
22+
{
23+
$backend_driver = $sqlWalker->getConnection()->getDriver()->getName();
24+
25+
// test if we are using MySQL
26+
if (mb_strpos($backend_driver, 'mysql') !== false) {
27+
// YES we are using MySQL
28+
// how do we know what type $this->first is? For now hardcoding
29+
// type(t.value) = JSON for MySQL. JSONB for others.
30+
// alternatively, test if true: $this->first->dispatch($sqlWalker)==='b2_.value',
31+
// b4_.value for /bolt/new/showcases
32+
if ($this->first->dispatch($sqlWalker) === 'b2_.value' ||
33+
$this->first->dispatch($sqlWalker) === 'b4_.value') {
34+
return $this->first->dispatch($sqlWalker);
35+
}
36+
}
37+
38+
return sprintf('CAST(%s AS %s)',
39+
$this->first->dispatch($sqlWalker),
40+
$this->second
41+
);
42+
}
43+
44+
public function parse(Parser $parser): void
45+
{
46+
$parser->match(Lexer::T_IDENTIFIER);
47+
$parser->match(Lexer::T_OPEN_PARENTHESIS);
48+
$this->first = $parser->ArithmeticPrimary();
49+
$parser->match(Lexer::T_AS);
50+
$parser->match(Lexer::T_IDENTIFIER);
51+
$this->second = $parser->getLexer()->token['value'];
52+
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
53+
}
54+
}

src/Doctrine/Version.php

+50-11
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,31 @@
99
use Doctrine\DBAL\Driver\PDOConnection;
1010
use Doctrine\DBAL\Platforms\MariaDb1027Platform;
1111
use Doctrine\DBAL\Platforms\MySQL57Platform;
12+
use Doctrine\DBAL\Platforms\PostgreSQL92Platform;
1213
use Doctrine\DBAL\Platforms\SqlitePlatform;
1314

1415
class Version
1516
{
1617
/**
1718
* We're g̶u̶e̶s̶s̶i̶n̶g̶ doing empirical research on which versions of SQLite
1819
* support JSON. So far, tests indicate:
20+
* https://www.sqlite.org/json1.html --> JSON since SQLite version 3.9.0 (2015-10-14)
21+
* Docker uses an outdated version of SQLite/PHP combi with wrong implementation of JSON1 extensions:
22+
* https://www.talvbansal.me/blog/unit-tests-with-json-columns-in-sqlite/
23+
* This explains why BOLT is working in stand-alone production/dev envs. but not in unit tests using Docker
24+
* We need to replace this with a proper function test, instead of a guestimate.
25+
* - 3.32.2 - OK (Wytse's FBSD 12.1 \w PHP 7.2)
1926
* - 3.20.1 - Not OK (Travis PHP 7.2)
2027
* - 3.27.2 - OK (Bob's Raspberry Pi, running PHP 7.3.11 on Raspbian)
2128
* - 3.28.0 - OK (Travis PHP 7.3)
2229
* - 3.28.0 - Not OK (Bob's PHP 7.2, installed with Brew)
2330
* - 3.29.0 - OK (MacOS Mojave)
2431
* - 3.30.1 - OK (MacOS Catalina)
2532
*/
33+
// JSON supported since SQLite version 3.9.0
2634
public const SQLITE_WITH_JSON = '3.27.0';
27-
public const PHP_WITH_SQLITE = '7.3.0';
35+
// PHP supports SQLite since version 5.3.0, but not always bundled with JSON support!
36+
public const PHP_WITH_SQLITE_JSON_SUPPORT = '7.3.0';
2837

2938
/** @var Connection */
3039
private $connection;
@@ -79,14 +88,23 @@ public function hasJson(): bool
7988
$platform = $this->connection->getDatabasePlatform();
8089

8190
if ($platform instanceof SqlitePlatform) {
82-
return $this->checkSqliteVersion();
91+
// new method to test for JSON support
92+
return $this->hasSQLiteJSONSupport();
93+
94+
// temporarily leave this in, until above method is fully tested
95+
// return $this->checkSqliteVersion();
8396
}
8497

8598
// MySQL80Platform is implicitly included with MySQL57Platform
8699
if ($platform instanceof MySQL57Platform || $platform instanceof MariaDb1027Platform) {
87100
return true;
88101
}
89102

103+
// PostgreSQL supports JSON from v9.2 and above, later versions are implicitly included
104+
if ($platform instanceof PostgreSQL92Platform) {
105+
return true;
106+
}
107+
90108
return false;
91109
}
92110

@@ -104,19 +122,40 @@ public function hasCast(): bool
104122
return true;
105123
}
106124

107-
private function checkSqliteVersion(): bool
125+
/* leave until alternative method fully tested */
126+
// private function checkSqliteVersion(): bool
127+
// {
128+
// /** @var PDOConnection */
129+
// $wrapped = $this->connection->getWrappedConnection();
130+
131+
// // If the wrapper doesn't have `getAttribute`, we bail…
132+
// if (! method_exists($wrapped, 'getAttribute')) {
133+
// return false;
134+
// }
135+
136+
// [$client_version] = explode(' - ', $wrapped->getAttribute(\PDO::ATTR_CLIENT_VERSION));
137+
138+
// return (version_compare($client_version, self::SQLITE_WITH_JSON) > 0) &&
139+
// (version_compare(PHP_VERSION, self::PHP_WITH_SQLITE_JSON_SUPPORT) > 0);
140+
//}
141+
142+
private function hasSQLiteJSONSupport(): bool
108143
{
109-
/** @var PDOConnection $wrapped */
110-
$wrapped = $this->connection->getWrappedConnection();
144+
// Here we can test SQLite for JSON support
145+
// This should also work for MySQL
146+
// For PostgreSQL a different query is required, but this should be easy to expand (check for driver + adjust query)
147+
// Query = "SELECT JSON_EXTRACT('{"jsonfunctionalitytest":["succes"]}', '$.jsonfunctionalitytest') as value";
148+
// Should return value ["succes"], and throw an error if JSON unsupported.
111149

112-
// If the wrapper doesn't have `getAttribute`, we bail…
113-
if (! method_exists($wrapped, 'getAttribute')) {
150+
try {
151+
$query = $this->connection->createQueryBuilder();
152+
$query
153+
->select('JSON_EXTRACT(\'{"jsonfunctionalitytest":["succes"]}\', \'$.jsonfunctionalitytest\') as value');
154+
$query->execute();
155+
} catch (\Throwable $e) {
114156
return false;
115157
}
116158

117-
[$client_version] = explode(' - ', $wrapped->getAttribute(\PDO::ATTR_CLIENT_VERSION));
118-
119-
return (version_compare($client_version, self::SQLITE_WITH_JSON) > 0) &&
120-
(version_compare(PHP_VERSION, self::PHP_WITH_SQLITE) > 0);
159+
return true;
121160
}
122161
}

src/Entity/FieldTranslation.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class FieldTranslation implements TranslationInterface
2121
*/
2222
private $id;
2323

24-
/** @ORM\Column(type="json") */
24+
/** @ORM\Column(type="json", options={"jsonb": true}) */
2525
protected $value = [];
2626

2727
public function getId(): ?int

src/Entity/Log.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class Log
3838
/** @ORM\Column(name="extra", type="array", nullable=true) */
3939
private $extra;
4040

41-
/** @ORM\Column(name="user", type="array", nullable=true) */
41+
/** @ORM\Column(name="`user`", type="array", nullable=true) */
4242
private $user;
4343

4444
/** @ORM\Column(type="content", type="integer", nullable=true) */

src/Repository/ContentRepository.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,14 @@ public function searchNaive(string $searchTerm, int $page, int $amountPerPage, C
9494
$qb = $this->getQueryBuilder()
9595
->select('partial content.{id}');
9696

97+
// proper JSON wrapping solves a lot of problems (added PostgreSQL compatibility)
98+
$connection = $qb->getEntityManager()->getConnection();
99+
[$where] = JsonHelper::wrapJsonFunction('t.value', $searchTerm, $connection);
100+
97101
$qb->addSelect('f')
98102
->innerJoin('content.fields', 'f')
99103
->innerJoin('f.translations', 't')
100-
->andWhere($qb->expr()->like('t.value', ':search'))
104+
->andWhere($qb->expr()->like($where, ':search'))
101105
->setParameter('search', '%' . $searchTerm . '%');
102106

103107
// These are the ID's of content we need.

src/Storage/Directive/OrderDirective.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ private function setOrderBy(QueryInterface $query, string $order, string $direct
115115
} else {
116116
// Note the `lower()` in the `addOrderBy()`. It is essential to sorting the
117117
// results correctly. See also https://github.com/bolt/core/issues/1190
118+
// again: lower breaks postgresql jsonb compatibility, first cast as txt
118119
$query
119120
->getQueryBuilder()
120-
->addOrderBy('lower(' . $translationsAlias . '.value)', $direction);
121+
->addOrderBy('lower(CAST(' . $translationsAlias . '.value as TEXT))', $direction);
121122
}
122123
$query->incrementIndex();
123124
} else {

src/Storage/Directive/RandomDirectiveHandler.php

+5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ public function __invoke(QueryInterface $query, $value, &$directives): void
3131

3232
return;
3333
}
34+
if ($this->version->getPlatform()['driver_name'] === 'pgsql') {
35+
$query->getQueryBuilder()->addSelect('RAND(2) as HIDDEN rand')->orderBy('rand');
36+
37+
return;
38+
}
3439

3540
$query->getQueryBuilder()->addSelect('RAND(0) as HIDDEN rand')->orderBy('rand');
3641
}

src/Storage/SelectQuery.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,9 @@ private function getRegularFieldExpression(Filter $filter, EntityManagerInterfac
537537

538538
$originalLeftExpression = 'content.' . $filter->getKey();
539539
// LOWER() added to query to enable case insensitive search of JSON values. Used in conjunction with converting $params of setParameter() to lowercase.
540-
$newLeftExpression = JsonHelper::wrapJsonFunction('LOWER(' . $valueAlias . ')', null, $em->getConnection());
540+
// BUG SQLSTATE[42883]: Undefined function: 7 ERROR: function lower(jsonb) does not exist
541+
// We want to be able to search case-insensitive, database-agnostic, have to think of a good way..
542+
$newLeftExpression = JsonHelper::wrapJsonFunction($valueAlias, null, $em->getConnection());
541543
$valueWhere = $filter->getExpression();
542544
$valueWhere = str_replace($originalLeftExpression, $newLeftExpression, $valueWhere);
543545
$expr->add($valueWhere);

templates/helpers/_field_blocks.twig

+4-1
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@
4343
{% block extended_fields %}
4444

4545
{# Special case for 'select' fields: if it's a multiple select, the field is an array. #}
46+
{# I dont know what is going on here, but field.selectedIds for pgsql showcase/mr-jean-feeney (and others) #}
47+
{# yield field values (strings containing full name!), NOT integer IDs.. #}
48+
{# TODO: FIX IT, bypassing for now by selecting on 'value' (string) and not 'id' (int) #}
4649
{% if type == "select" and field is not empty %}
4750
<p><strong>{{ field|label }}: </strong></p>
4851
<ul>
4952
{% if field.contentSelect %}
50-
{% setcontent selected = field.contentType where {'id': field.selectedIds} returnmultiple %}
53+
{% setcontent selected = field.contentType where {'value': field.selectedIds} returnmultiple %}
5154
{% for record in selected %}
5255
<li><a href="{{ record|link }}">{{ record|title }}</a></li>
5356
{% endfor %}

0 commit comments

Comments
 (0)