diff --git a/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php index 01486f35..8a007057 100644 --- a/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php +++ b/src/Tqdev/PhpCrudApi/Column/Reflection/ReflectedColumn.php @@ -87,7 +87,7 @@ public static function fromReflection(GenericReflection $reflection, array $colu return new ReflectedColumn($name, $type, $length, $precision, $scale, $nullable, $pk, $fk); } - public static function fromJson(/* object */$json): ReflectedColumn + public static function fromJson( /* object */$json): ReflectedColumn { $name = $json->name; $type = $json->type; @@ -167,6 +167,11 @@ public function isGeometry(): bool return $this->type == 'geometry'; } + public function isJson(): bool + { + return $this->type == 'json'; + } + public function isInteger(): bool { return in_array($this->type, ['integer', 'bigint', 'smallint', 'tinyint']); diff --git a/src/Tqdev/PhpCrudApi/Database/DataConverter.php b/src/Tqdev/PhpCrudApi/Database/DataConverter.php index 1f8f7b46..461d658c 100644 --- a/src/Tqdev/PhpCrudApi/Database/DataConverter.php +++ b/src/Tqdev/PhpCrudApi/Database/DataConverter.php @@ -25,6 +25,8 @@ private function convertRecordValue($conversion, $value) return (int) $value; case 'float': return (float) $value; + case 'json': + return \json_decode($value); case 'decimal': return number_format($value, $args[0], '.', ''); } @@ -42,6 +44,9 @@ private function getRecordValueConversion(ReflectedColumn $column): string if (in_array($column->getType(), ['float', 'double'])) { return 'float'; } + if ($column->isJson()) { + return 'json'; + } if (in_array($this->driver, ['sqlite']) && in_array($column->getType(), ['decimal'])) { return 'decimal|' . $column->getScale(); } @@ -72,6 +77,8 @@ private function convertInputValue($conversion, $value) return $value ? 1 : 0; case 'base64url_to_base64': return str_pad(strtr($value, '-_', '+/'), ceil(strlen($value) / 4) * 4, '=', STR_PAD_RIGHT); + case 'json_encode': + return json_encode($value); } return $value; } @@ -84,6 +91,9 @@ private function getInputValueConversion(ReflectedColumn $column): string if ($column->isBinary()) { return 'base64url_to_base64'; } + if ($column->isJson()) { + return 'json_encode'; + } return 'none'; } diff --git a/src/Tqdev/PhpCrudApi/Database/TypeConverter.php b/src/Tqdev/PhpCrudApi/Database/TypeConverter.php index e9070d90..9c26bf19 100644 --- a/src/Tqdev/PhpCrudApi/Database/TypeConverter.php +++ b/src/Tqdev/PhpCrudApi/Database/TypeConverter.php @@ -72,7 +72,6 @@ public function __construct(string $driver) 'year' => 'integer', 'enum' => 'varchar', 'set' => 'varchar', - 'json' => 'clob', ], 'pgsql' => [ 'bigserial' => 'bigint', @@ -87,8 +86,7 @@ public function __construct(string $driver) 'double precision' => 'double', 'inet' => 'integer', //'interval [ fields ]' - 'json' => 'clob', - 'jsonb' => 'clob', + 'jsonb' => 'json', 'line' => 'geometry', 'lseg' => 'geometry', 'macaddr' => 'varchar', @@ -140,7 +138,7 @@ public function __construct(string $driver) 'int4' => 'integer', 'int8' => 'bigint', 'double precision' => 'double', - 'datetime' => 'timestamp' + 'datetime' => 'timestamp', ], ]; @@ -187,6 +185,7 @@ public function __construct(string $driver) 'varchar' => true, // extra: 'geometry' => true, + 'json' => true, ]; public function toJdbc(string $type, string $size): string diff --git a/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php index c044f80d..d9c1df0f 100644 --- a/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php +++ b/src/Tqdev/PhpCrudApi/Middleware/SanitationMiddleware.php @@ -5,9 +5,9 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; -use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; use Tqdev\PhpCrudApi\Column\ReflectionService; +use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; +use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; use Tqdev\PhpCrudApi\Controller\Responder; use Tqdev\PhpCrudApi\Middleware\Base\Middleware; use Tqdev\PhpCrudApi\Middleware\Router\Router; diff --git a/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php index f0562e72..46f172a4 100644 --- a/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php +++ b/src/Tqdev/PhpCrudApi/Middleware/ValidationMiddleware.php @@ -6,8 +6,8 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Tqdev\PhpCrudApi\Column\ReflectionService; -use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; use Tqdev\PhpCrudApi\Column\Reflection\ReflectedColumn; +use Tqdev\PhpCrudApi\Column\Reflection\ReflectedTable; use Tqdev\PhpCrudApi\Controller\Responder; use Tqdev\PhpCrudApi\Middleware\Base\Middleware; use Tqdev\PhpCrudApi\Middleware\Router\Router; @@ -16,202 +16,205 @@ class ValidationMiddleware extends Middleware { - private $reflection; + private $reflection; - public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) - { - parent::__construct($router, $responder, $properties); - $this->reflection = $reflection; - } + public function __construct(Router $router, Responder $responder, array $properties, ReflectionService $reflection) + { + parent::__construct($router, $responder, $properties); + $this->reflection = $reflection; + } - private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: ResponseInterface?*/ - { - $context = (array) $record; - $details = array(); - $tableName = $table->getName(); - foreach ($context as $columnName => $value) { - if ($table->hasColumn($columnName)) { - $column = $table->getColumn($columnName); - $valid = call_user_func($handler, $operation, $tableName, $column->serialize(), $value, $context); - if ($valid === true || $valid === '') { - $valid = $this->validateType($table, $column, $value); - } - if ($valid !== true && $valid !== '') { - $details[$columnName] = $valid; - } - } - } - if (count($details) > 0) { - return $this->responder->error(ErrorCode::INPUT_VALIDATION_FAILED, $tableName, $details); - } - return null; - } + private function callHandler($handler, $record, string $operation, ReflectedTable $table) /*: ResponseInterface?*/ + { + $context = (array) $record; + $details = array(); + $tableName = $table->getName(); + foreach ($context as $columnName => $value) { + if ($table->hasColumn($columnName)) { + $column = $table->getColumn($columnName); + $valid = call_user_func($handler, $operation, $tableName, $column->serialize(), $value, $context); + if ($valid === true || $valid === '') { + $valid = $this->validateType($table, $column, $value); + } + if ($valid !== true && $valid !== '') { + $details[$columnName] = $valid; + } + } + } + if (count($details) > 0) { + return $this->responder->error(ErrorCode::INPUT_VALIDATION_FAILED, $tableName, $details); + } + return null; + } - private function validateType(ReflectedTable $table, ReflectedColumn $column, $value) - { - $tables = $this->getArrayProperty('tables', 'all'); - $types = $this->getArrayProperty('types', 'all'); - if ( - (in_array('all', $tables) || in_array($table->getName(), $tables)) && - (in_array('all', $types) || in_array($column->getType(), $types)) - ) { - if (is_null($value)) { - return ($column->getNullable() ? true : "cannot be null"); - } - if (is_string($value)) { - // check for whitespace - switch ($column->getType()) { - case 'varchar': - case 'clob': - break; - default: - if (strlen(trim($value)) != strlen($value)) { - return 'illegal whitespace'; - } - break; - } - // try to parse - switch ($column->getType()) { - case 'integer': - case 'bigint': - if ( - filter_var($value, FILTER_SANITIZE_NUMBER_INT) !== $value || - filter_var($value, FILTER_VALIDATE_INT) === false - ) { - return 'invalid integer'; - } - break; - case 'decimal': - if (strpos($value, '.') !== false) { - list($whole, $decimals) = explode('.', ltrim($value, '-'), 2); - } else { - list($whole, $decimals) = array(ltrim($value, '-'), ''); - } - if (strlen($whole) > 0 && !ctype_digit($whole)) { - return 'invalid decimal'; - } - if (strlen($decimals) > 0 && !ctype_digit($decimals)) { - return 'invalid decimal'; - } - if (strlen($whole) > $column->getPrecision() - $column->getScale()) { - return 'decimal too large'; - } - if (strlen($decimals) > $column->getScale()) { - return 'decimal too precise'; - } - break; - case 'float': - case 'double': - if ( - filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT) !== $value || - filter_var($value, FILTER_VALIDATE_FLOAT) === false - ) { - return 'invalid float'; - } - break; - case 'boolean': - if (!in_array(strtolower($value), array('true', 'false'))) { - return 'invalid boolean'; - } - break; - case 'date': - if (date_create_from_format('Y-m-d', $value) === false) { - return 'invalid date'; - } - break; - case 'time': - if (date_create_from_format('H:i:s', $value) === false) { - return 'invalid time'; - } - break; - case 'timestamp': - if (date_create_from_format('Y-m-d H:i:s', $value) === false) { - return 'invalid timestamp'; - } - break; - case 'clob': - case 'varchar': - if ($column->hasLength() && mb_strlen($value, 'UTF-8') > $column->getLength()) { - return 'string too long'; - } - break; - case 'blob': - case 'varbinary': - if (base64_decode($value, true) === false) { - return 'invalid base64'; - } - if ($column->hasLength() && strlen(base64_decode($value)) > $column->getLength()) { - return 'string too long'; - } - break; - case 'geometry': - // no checks yet - break; - } - } else { // check non-string types - switch ($column->getType()) { - case 'integer': - case 'bigint': - if (!is_int($value)) { - return 'invalid integer'; - } - break; - case 'float': - case 'double': - if (!is_float($value) && !is_int($value)) { - return 'invalid float'; - } - break; - case 'boolean': - if (!is_bool($value) && ($value !== 0) && ($value !== 1)) { - return 'invalid boolean'; - } - break; - default: - return 'invalid ' . $column->getType(); - } - } - // extra checks - switch ($column->getType()) { - case 'integer': // 4 byte signed - $value = filter_var($value, FILTER_VALIDATE_INT); - if ($value > 2147483647 || $value < -2147483648) { - return 'invalid integer'; - } - break; - } - } - return (true); - } + private function validateType(ReflectedTable $table, ReflectedColumn $column, $value) + { + $tables = $this->getArrayProperty('tables', 'all'); + $types = $this->getArrayProperty('types', 'all'); + if ( + (in_array('all', $tables) || in_array($table->getName(), $tables)) && + (in_array('all', $types) || in_array($column->getType(), $types)) + ) { + if (is_null($value)) { + return ($column->getNullable() ? true : "cannot be null"); + } + if (is_string($value)) { + // check for whitespace + switch ($column->getType()) { + case 'varchar': + case 'clob': + break; + default: + if (strlen(trim($value)) != strlen($value)) { + return 'illegal whitespace'; + } + break; + } + // try to parse + switch ($column->getType()) { + case 'integer': + case 'bigint': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_INT) !== $value || + filter_var($value, FILTER_VALIDATE_INT) === false + ) { + return 'invalid integer'; + } + break; + case 'decimal': + if (strpos($value, '.') !== false) { + list($whole, $decimals) = explode('.', ltrim($value, '-'), 2); + } else { + list($whole, $decimals) = array(ltrim($value, '-'), ''); + } + if (strlen($whole) > 0 && !ctype_digit($whole)) { + return 'invalid decimal'; + } + if (strlen($decimals) > 0 && !ctype_digit($decimals)) { + return 'invalid decimal'; + } + if (strlen($whole) > $column->getPrecision() - $column->getScale()) { + return 'decimal too large'; + } + if (strlen($decimals) > $column->getScale()) { + return 'decimal too precise'; + } + break; + case 'float': + case 'double': + if ( + filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT) !== $value || + filter_var($value, FILTER_VALIDATE_FLOAT) === false + ) { + return 'invalid float'; + } + break; + case 'boolean': + if (!in_array(strtolower($value), array('true', 'false'))) { + return 'invalid boolean'; + } + break; + case 'date': + if (date_create_from_format('Y-m-d', $value) === false) { + return 'invalid date'; + } + break; + case 'time': + if (date_create_from_format('H:i:s', $value) === false) { + return 'invalid time'; + } + break; + case 'timestamp': + if (date_create_from_format('Y-m-d H:i:s', $value) === false) { + return 'invalid timestamp'; + } + break; + case 'clob': + case 'varchar': + if ($column->hasLength() && mb_strlen($value, 'UTF-8') > $column->getLength()) { + return 'string too long'; + } + break; + case 'blob': + case 'varbinary': + if (base64_decode($value, true) === false) { + return 'invalid base64'; + } + if ($column->hasLength() && strlen(base64_decode($value)) > $column->getLength()) { + return 'string too long'; + } + break; + case 'geometry': + // no checks yet + break; + } + } else { // check non-string types + switch ($column->getType()) { + case 'integer': + case 'bigint': + if (!is_int($value)) { + return 'invalid integer'; + } + break; + case 'float': + case 'double': + if (!is_float($value) && !is_int($value)) { + return 'invalid float'; + } + break; + case 'boolean': + if (!is_bool($value) && ($value !== 0) && ($value !== 1)) { + return 'invalid boolean'; + } + break; + case 'json': + // no checks yet + break; + default: + return 'invalid ' . $column->getType(); + } + } + // extra checks + switch ($column->getType()) { + case 'integer': // 4 byte signed + $value = filter_var($value, FILTER_VALIDATE_INT); + if ($value > 2147483647 || $value < -2147483648) { + return 'invalid integer'; + } + break; + } + } + return (true); + } - public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface - { - $operation = RequestUtils::getOperation($request); - if (in_array($operation, ['create', 'update', 'increment'])) { - $tableName = RequestUtils::getPathSegment($request, 2); - if ($this->reflection->hasTable($tableName)) { - $record = $request->getParsedBody(); - if ($record !== null) { - $handler = $this->getProperty('handler', ''); - if ($handler !== '') { - $table = $this->reflection->getTable($tableName); - if (is_array($record)) { - foreach ($record as $r) { - $response = $this->callHandler($handler, $r, $operation, $table); - if ($response !== null) { - return $response; - } - } - } else { - $response = $this->callHandler($handler, $record, $operation, $table); - if ($response !== null) { - return $response; - } - } - } - } - } - } - return $next->handle($request); - } + public function process(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface + { + $operation = RequestUtils::getOperation($request); + if (in_array($operation, ['create', 'update', 'increment'])) { + $tableName = RequestUtils::getPathSegment($request, 2); + if ($this->reflection->hasTable($tableName)) { + $record = $request->getParsedBody(); + if ($record !== null) { + $handler = $this->getProperty('handler', ''); + if ($handler !== '') { + $table = $this->reflection->getTable($tableName); + if (is_array($record)) { + foreach ($record as $r) { + $response = $this->callHandler($handler, $r, $operation, $table); + if ($response !== null) { + return $response; + } + } + } else { + $response = $this->callHandler($handler, $record, $operation, $table); + if ($response !== null) { + return $response; + } + } + } + } + } + } + return $next->handle($request); + } } diff --git a/tests/fixtures/blog_mysql.sql b/tests/fixtures/blog_mysql.sql index 3caf94d4..0a0ddc04 100644 --- a/tests/fixtures/blog_mysql.sql +++ b/tests/fixtures/blog_mysql.sql @@ -137,7 +137,7 @@ CREATE TABLE `products` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `price` decimal(10,2) NOT NULL, - `properties` longtext NOT NULL, + `properties` json NOT NULL, `created_at` datetime NOT NULL, `deleted_at` datetime, PRIMARY KEY (`id`) diff --git a/tests/fixtures/blog_sqlite.sql b/tests/fixtures/blog_sqlite.sql index 40994511..d1d9e453 100644 --- a/tests/fixtures/blog_sqlite.sql +++ b/tests/fixtures/blog_sqlite.sql @@ -122,7 +122,7 @@ CREATE TABLE "products" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(255) NOT NULL, "price" decimal(10,2) NOT NULL, - "properties" clob NOT NULL, + "properties" json NOT NULL, "created_at" datetime NOT NULL, "deleted_at" datetime NULL ); diff --git a/tests/fixtures/blog_sqlsrv.sql b/tests/fixtures/blog_sqlsrv.sql index 3c95e600..2637dec3 100644 --- a/tests/fixtures/blog_sqlsrv.sql +++ b/tests/fixtures/blog_sqlsrv.sql @@ -136,6 +136,10 @@ DROP TABLE [nopk] END GO +DROP TYPE IF EXISTS [json]; + +IF TYPE_ID('json') IS NULL CREATE TYPE [json] FROM [ntext]; + DROP SEQUENCE IF EXISTS [categories_id_seq] GO CREATE SEQUENCE [categories_id_seq] AS int START WITH 1 INCREMENT BY 1 NO CACHE @@ -258,7 +262,7 @@ CREATE TABLE [products]( [id] [int] NOT NULL CONSTRAINT [products_id_def] DEFAULT NEXT VALUE FOR [products_id_seq], [name] [nvarchar](255) NOT NULL, [price] [decimal](10,2) NOT NULL, - [properties] [xml] NOT NULL, + [properties] [json] NOT NULL, [created_at] [datetime2](0) NOT NULL, [deleted_at] [datetime2](0), CONSTRAINT [products_pkey] PRIMARY KEY CLUSTERED([id] ASC) diff --git a/tests/functional/003_columns/001_get_database.log b/tests/functional/003_columns/001_get_database.log index 97ca1f52..958c20ee 100644 --- a/tests/functional/003_columns/001_get_database.log +++ b/tests/functional/003_columns/001_get_database.log @@ -6,4 +6,4 @@ GET /columns Content-Type: application/json; charset=utf-8 Content-Length: 2840 -{"tables":[{"name":"barcodes","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]},{"name":"categories","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"icon","type":"blob","nullable":true}]},{"name":"comments","type":"table","columns":[{"name":"id","type":"bigint","pk":true},{"name":"post_id","type":"integer","fk":"posts"},{"name":"message","type":"varchar","length":255},{"name":"category_id","type":"integer","fk":"categories"}]},{"name":"countries","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"shape","type":"geometry"}]},{"name":"events","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"datetime","type":"timestamp","nullable":true},{"name":"visitors","type":"bigint","nullable":true}]},{"name":"kunsthåndværk","type":"table","columns":[{"name":"id","type":"varchar","length":36,"pk":true},{"name":"Umlauts ä_ö_ü-COUNT","type":"integer"},{"name":"user_id","type":"integer","fk":"users"},{"name":"invisible_id","type":"varchar","length":36,"nullable":true,"fk":"invisibles"}]},{"name":"nopk","type":"table","columns":[{"name":"id","type":"varchar","length":36}]},{"name":"post_tags","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"post_id","type":"integer","fk":"posts"},{"name":"tag_id","type":"integer","fk":"tags"}]},{"name":"posts","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"user_id","type":"integer","fk":"users"},{"name":"category_id","type":"integer","fk":"categories"},{"name":"content","type":"varchar","length":255}]},{"name":"products","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"price","type":"decimal","precision":10,"scale":2},{"name":"properties","type":"clob"},{"name":"created_at","type":"timestamp"},{"name":"deleted_at","type":"timestamp","nullable":true}]},{"name":"tag_usage","type":"view","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"count","type":"bigint"}]},{"name":"tags","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"is_important","type":"boolean"}]},{"name":"users","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"username","type":"varchar","length":255},{"name":"password","type":"varchar","length":255},{"name":"location","type":"geometry","nullable":true}]}]} +{"tables":[{"name":"barcodes","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"product_id","type":"integer","fk":"products"},{"name":"hex","type":"varchar","length":255},{"name":"bin","type":"blob"},{"name":"ip_address","type":"varchar","length":15,"nullable":true}]},{"name":"categories","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"icon","type":"blob","nullable":true}]},{"name":"comments","type":"table","columns":[{"name":"id","type":"bigint","pk":true},{"name":"post_id","type":"integer","fk":"posts"},{"name":"message","type":"varchar","length":255},{"name":"category_id","type":"integer","fk":"categories"}]},{"name":"countries","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"shape","type":"geometry"}]},{"name":"events","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"datetime","type":"timestamp","nullable":true},{"name":"visitors","type":"bigint","nullable":true}]},{"name":"kunsthåndværk","type":"table","columns":[{"name":"id","type":"varchar","length":36,"pk":true},{"name":"Umlauts ä_ö_ü-COUNT","type":"integer"},{"name":"user_id","type":"integer","fk":"users"},{"name":"invisible_id","type":"varchar","length":36,"nullable":true,"fk":"invisibles"}]},{"name":"nopk","type":"table","columns":[{"name":"id","type":"varchar","length":36}]},{"name":"post_tags","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"post_id","type":"integer","fk":"posts"},{"name":"tag_id","type":"integer","fk":"tags"}]},{"name":"posts","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"user_id","type":"integer","fk":"users"},{"name":"category_id","type":"integer","fk":"categories"},{"name":"content","type":"varchar","length":255}]},{"name":"products","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"price","type":"decimal","precision":10,"scale":2},{"name":"properties","type":"json"},{"name":"created_at","type":"timestamp"},{"name":"deleted_at","type":"timestamp","nullable":true}]},{"name":"tag_usage","type":"view","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"count","type":"bigint"}]},{"name":"tags","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"name","type":"varchar","length":255},{"name":"is_important","type":"boolean"}]},{"name":"users","type":"table","columns":[{"name":"id","type":"integer","pk":true},{"name":"username","type":"varchar","length":255},{"name":"password","type":"varchar","length":255},{"name":"location","type":"geometry","nullable":true}]}]}