diff --git a/README.md b/README.md index 5ad72e8..3086922 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ PHP client for [ClickHouse](https://clickhouse.com) — fast, lightweight, no de - Sync & async (parallel) SELECT queries - [Native query parameters](doc/native-params.md) — server-side `{name:Type}` binding, SQL injection impossible -- [Rich type system](doc/types.md) — Boolean, Int64, Decimal, UUID, IPv4/IPv6, DateTime64, Date32, Map, Tuple +- [Rich type system](doc/types.md) — Boolean, integer and floating-point types, Decimal, UUID, IPv4/IPv6, DateTime64, Date32, Map, Tuple - Bulk inserts: arrays, CSV files, streams - [Generators](doc/generators.md) — memory-efficient iteration for large resultsets - HTTP compression (gzip) for inserts diff --git a/doc/types.md b/doc/types.md index 10afd2b..0d9bd66 100644 --- a/doc/types.md +++ b/doc/types.md @@ -6,6 +6,19 @@ All types implement `ClickHouseDB\Type\Type` interface and work with `insert()`, ## Numeric Types +### Integer and floating-point types + +The integer types `Int8`, `Int16`, `Int32`, `Int64`, `Int128`, `Int256` and +`UInt8`, `UInt16`, `UInt32`, `UInt64`, `UInt128`, `UInt256`, plus `Float32` +and `Float64`, preserve their string representation when used in inserts, +bindings, or comparisons: + +```php +use ClickHouseDB\Type\UInt128; + +$db->insert('table', [[UInt128::fromString('340282366920938463463374607431768211455')]], ['value']); +``` + ### UInt64 Large unsigned integers that overflow PHP's `int` range. @@ -44,6 +57,10 @@ $db->insert('table', [ ], ['price']); ``` +`Decimal32`, `Decimal64`, `Decimal128`, and `Decimal256` are available for +explicit ClickHouse decimal column types. ClickHouse `Bool` values use the +existing `Boolean` class because `Bool` is a reserved PHP keyword. + ## Date & Time Types ### Date and DateTime diff --git a/docs/index.md b/docs/index.md index 173588e..d680406 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ PHP client for [ClickHouse](https://clickhouse.com) — fast, lightweight, zero - Sync & async (parallel) SELECT queries - [Native query parameters](native-params) — server-side `{name:Type}` binding, SQL injection impossible -- [Rich type system](types) — Boolean, Int64, Decimal, UUID, IPv4/IPv6, DateTime64, Date32, Map, Tuple +- [Rich type system](types) — Boolean, integer and floating-point types, Decimal, UUID, IPv4/IPv6, DateTime64, Date32, Map, Tuple - Bulk inserts: arrays, CSV files, streams - [Generators](generators) — memory-efficient iteration for large resultsets - HTTP compression (gzip) for inserts diff --git a/src/Type/Decimal128.php b/src/Type/Decimal128.php new file mode 100644 index 0000000..feea589 --- /dev/null +++ b/src/Type/Decimal128.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (! is_numeric($value)) { + throw new InvalidArgumentException('Decimal128 expects a numeric string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Decimal256.php b/src/Type/Decimal256.php new file mode 100644 index 0000000..20092d1 --- /dev/null +++ b/src/Type/Decimal256.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (! is_numeric($value)) { + throw new InvalidArgumentException('Decimal256 expects a numeric string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Decimal32.php b/src/Type/Decimal32.php new file mode 100644 index 0000000..71ff740 --- /dev/null +++ b/src/Type/Decimal32.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (! is_numeric($value)) { + throw new InvalidArgumentException('Decimal32 expects a numeric string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Decimal64.php b/src/Type/Decimal64.php new file mode 100644 index 0000000..90406ad --- /dev/null +++ b/src/Type/Decimal64.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (! is_numeric($value)) { + throw new InvalidArgumentException('Decimal64 expects a numeric string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Float32.php b/src/Type/Float32.php new file mode 100644 index 0000000..f2e8738 --- /dev/null +++ b/src/Type/Float32.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (! is_numeric($value)) { + throw new InvalidArgumentException('Float32 expects a numeric string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Float64.php b/src/Type/Float64.php new file mode 100644 index 0000000..0216210 --- /dev/null +++ b/src/Type/Float64.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (! is_numeric($value)) { + throw new InvalidArgumentException('Float64 expects a numeric string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Int128.php b/src/Type/Int128.php new file mode 100644 index 0000000..b5a3654 --- /dev/null +++ b/src/Type/Int128.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('Int128 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Int16.php b/src/Type/Int16.php new file mode 100644 index 0000000..47da4e3 --- /dev/null +++ b/src/Type/Int16.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('Int16 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Int256.php b/src/Type/Int256.php new file mode 100644 index 0000000..fce97ee --- /dev/null +++ b/src/Type/Int256.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('Int256 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Int32.php b/src/Type/Int32.php new file mode 100644 index 0000000..88a8676 --- /dev/null +++ b/src/Type/Int32.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('Int32 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Int8.php b/src/Type/Int8.php new file mode 100644 index 0000000..344901b --- /dev/null +++ b/src/Type/Int8.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('Int8 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/ScalarNumericType.php b/src/Type/ScalarNumericType.php new file mode 100644 index 0000000..ef561d2 --- /dev/null +++ b/src/Type/ScalarNumericType.php @@ -0,0 +1,14 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('UInt128 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/UInt16.php b/src/Type/UInt16.php new file mode 100644 index 0000000..2fe3b2a --- /dev/null +++ b/src/Type/UInt16.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('UInt16 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/UInt256.php b/src/Type/UInt256.php new file mode 100644 index 0000000..b2d038b --- /dev/null +++ b/src/Type/UInt256.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('UInt256 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/UInt32.php b/src/Type/UInt32.php new file mode 100644 index 0000000..c56652d --- /dev/null +++ b/src/Type/UInt32.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('UInt32 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/UInt8.php b/src/Type/UInt8.php new file mode 100644 index 0000000..20a6136 --- /dev/null +++ b/src/Type/UInt8.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): static + { + if (preg_match('/^-?\d+$/', $value) !== 1) { + throw new InvalidArgumentException('UInt8 expects an integer string, got: ' . $value); + } + + return new static($value); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Type/ScalarNumericIntegrationTest.php b/tests/Type/ScalarNumericIntegrationTest.php new file mode 100644 index 0000000..a05a3e7 --- /dev/null +++ b/tests/Type/ScalarNumericIntegrationTest.php @@ -0,0 +1,102 @@ +client->write(sprintf('DROP TABLE IF EXISTS %s', $table)); + $this->client->write(sprintf( + 'CREATE TABLE %s (value %s) ENGINE = Memory', + $table, + $typeName + )); + + $this->client->insert($table, [ + [$className::fromString($lower)], + [$className::fromString($higher)], + ]); + + // CAST the literal to the column type: Decimal vs Float comparison is + // unsupported on CH 21, and a bare Float64 literal 1.1 differs from the + // stored Float32 representation. + $statement = $this->client->select(sprintf( + 'SELECT value FROM %s WHERE value > CAST(%s AS %s)', + $table, + $className::fromString($lower), + $typeName + )); + + self::assertSame(1, $statement->count()); + } + + /** + * @return array + */ + public static function scalarTypes(): array + { + return [ + 'Int8' => ['typeName' => 'Int8', 'className' => Int8::class, 'lower' => '-2', 'higher' => '1'], + 'Int16' => ['typeName' => 'Int16', 'className' => Int16::class, 'lower' => '-2', 'higher' => '1'], + 'Int32' => ['typeName' => 'Int32', 'className' => Int32::class, 'lower' => '-2', 'higher' => '1'], + 'Int64' => ['typeName' => 'Int64', 'className' => Int64::class, 'lower' => '-2', 'higher' => '1'], + 'Int128' => ['typeName' => 'Int128', 'className' => Int128::class, 'lower' => '-2', 'higher' => '1'], + 'Int256' => ['typeName' => 'Int256', 'className' => Int256::class, 'lower' => '-2', 'higher' => '1'], + 'UInt8' => ['typeName' => 'UInt8', 'className' => UInt8::class, 'lower' => '1', 'higher' => '2'], + 'UInt16' => ['typeName' => 'UInt16', 'className' => UInt16::class, 'lower' => '1', 'higher' => '2'], + 'UInt32' => ['typeName' => 'UInt32', 'className' => UInt32::class, 'lower' => '1', 'higher' => '2'], + 'UInt64' => ['typeName' => 'UInt64', 'className' => UInt64::class, 'lower' => '1', 'higher' => '2'], + 'UInt128' => ['typeName' => 'UInt128', 'className' => UInt128::class, 'lower' => '1', 'higher' => '2'], + 'UInt256' => ['typeName' => 'UInt256', 'className' => UInt256::class, 'lower' => '1', 'higher' => '2'], + 'Float32' => ['typeName' => 'Float32', 'className' => Float32::class, 'lower' => '1.1', 'higher' => '2.2'], + 'Float64' => ['typeName' => 'Float64', 'className' => Float64::class, 'lower' => '1.1', 'higher' => '2.2'], + 'Decimal32' => ['typeName' => 'Decimal32(2)', 'className' => Decimal32::class, 'lower' => '1.1', 'higher' => '2.2'], + 'Decimal64' => ['typeName' => 'Decimal64(2)', 'className' => Decimal64::class, 'lower' => '1.1', 'higher' => '2.2'], + 'Decimal128' => ['typeName' => 'Decimal128(2)', 'className' => Decimal128::class, 'lower' => '1.1', 'higher' => '2.2'], + 'Decimal256' => ['typeName' => 'Decimal256(2)', 'className' => Decimal256::class, 'lower' => '1.1', 'higher' => '2.2'], + 'Bool' => ['typeName' => 'Bool', 'className' => Boolean::class, 'lower' => '0', 'higher' => '1'], + ]; + } +} diff --git a/tests/TypesTest.php b/tests/TypesTest.php index 9e17f96..02eefce 100644 --- a/tests/TypesTest.php +++ b/tests/TypesTest.php @@ -8,14 +8,31 @@ use ClickHouseDB\Type\Date32; use ClickHouseDB\Type\DateTime64; use ClickHouseDB\Type\Decimal; +use ClickHouseDB\Type\Decimal128; +use ClickHouseDB\Type\Decimal256; +use ClickHouseDB\Type\Decimal32; +use ClickHouseDB\Type\Decimal64; +use ClickHouseDB\Type\Float32; +use ClickHouseDB\Type\Float64; +use ClickHouseDB\Type\Int128; +use ClickHouseDB\Type\Int16; +use ClickHouseDB\Type\Int256; +use ClickHouseDB\Type\Int32; use ClickHouseDB\Type\Int64; +use ClickHouseDB\Type\Int8; use ClickHouseDB\Type\IPv4; use ClickHouseDB\Type\IPv6; use ClickHouseDB\Type\MapType; use ClickHouseDB\Type\TupleType; +use ClickHouseDB\Type\UInt128; +use ClickHouseDB\Type\UInt16; +use ClickHouseDB\Type\UInt256; +use ClickHouseDB\Type\UInt32; use ClickHouseDB\Type\UInt64; +use ClickHouseDB\Type\UInt8; use ClickHouseDB\Type\UUID; use DateTimeImmutable; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; class TypesTest extends TestCase @@ -74,6 +91,69 @@ public function testInt64ToString(): void self::assertSame('-42', (string) $int); } + /** + * @dataProvider scalarNumericTypes + */ + public function testScalarNumericTypesPreserveTheirValue(string $class, string $value): void + { + $type = $class::fromString($value); + + self::assertSame($value, $type->getValue()); + self::assertSame($value, (string) $type); + } + + /** + * @return array + */ + public static function scalarNumericTypes(): array + { + return [ + 'Int8' => ['class' => Int8::class, 'value' => '-128'], + 'Int16' => ['class' => Int16::class, 'value' => '-32768'], + 'Int32' => ['class' => Int32::class, 'value' => '-2147483648'], + 'Int128' => ['class' => Int128::class, 'value' => '-170141183460469231731687303715884105728'], + 'Int256' => ['class' => Int256::class, 'value' => '-1'], + 'UInt8' => ['class' => UInt8::class, 'value' => '255'], + 'UInt16' => ['class' => UInt16::class, 'value' => '65535'], + 'UInt128' => ['class' => UInt128::class, 'value' => '340282366920938463463374607431768211455'], + 'UInt256' => ['class' => UInt256::class, 'value' => '1'], + 'UInt32' => ['class' => UInt32::class, 'value' => '4294967295'], + 'Float32' => ['class' => Float32::class, 'value' => '1.25'], + 'Float64' => ['class' => Float64::class, 'value' => '1.234567890123'], + 'Decimal32' => ['class' => Decimal32::class, 'value' => '12.34'], + 'Decimal64' => ['class' => Decimal64::class, 'value' => '1234.5678'], + 'Decimal128' => ['class' => Decimal128::class, 'value' => '123456789.0123456789'], + 'Decimal256' => ['class' => Decimal256::class, 'value' => '12345678901234567890.1234567890'], + ]; + } + + /** + * @dataProvider invalidScalarNumericValues + */ + public function testScalarNumericTypesRejectNonNumericInput(string $class, string $value): void + { + $this->expectException(InvalidArgumentException::class); + $class::fromString($value); + } + + /** + * @return array + */ + public static function invalidScalarNumericValues(): array + { + return [ + 'int with sql injection' => ['class' => Int32::class, 'value' => '1; DROP TABLE users'], + 'int with float' => ['class' => Int8::class, 'value' => '1.5'], + 'int empty' => ['class' => UInt8::class, 'value' => ''], + 'int with letters' => ['class' => UInt128::class, 'value' => '12abc'], + 'int with parens' => ['class' => Int256::class, 'value' => '1) OR (1=1'], + 'float with sql injection' => ['class' => Float64::class, 'value' => '1.1 OR 1=1'], + 'float with letters' => ['class' => Float32::class, 'value' => 'abc'], + 'decimal with sql injection' => ['class' => Decimal64::class, 'value' => "1.1'); DROP TABLE users;--"], + 'decimal empty' => ['class' => Decimal32::class, 'value' => ''], + ]; + } + public function testDecimalFromStringGetValue(): void { $decimal = Decimal::fromString('123.456'); @@ -131,21 +211,21 @@ public function testDateTime64FromStringGetValue(): void public function testDateTime64FromDateTimePrecision0(): void { $dateTime = new DateTimeImmutable('2024-01-15 10:30:00.123456'); - $dt = DateTime64::fromDateTime($dateTime, 0); + $dt = DateTime64::fromDateTime($dateTime, 0); self::assertSame('2024-01-15 10:30:00', $dt->getValue()); } public function testDateTime64FromDateTimePrecision3(): void { $dateTime = new DateTimeImmutable('2024-01-15 10:30:00.123456'); - $dt = DateTime64::fromDateTime($dateTime, 3); + $dt = DateTime64::fromDateTime($dateTime, 3); self::assertSame('2024-01-15 10:30:00.123', $dt->getValue()); } public function testDateTime64FromDateTimePrecision6(): void { $dateTime = new DateTimeImmutable('2024-01-15 10:30:00.123456'); - $dt = DateTime64::fromDateTime($dateTime, 6); + $dt = DateTime64::fromDateTime($dateTime, 6); self::assertSame('2024-01-15 10:30:00.123456', $dt->getValue()); } @@ -158,7 +238,7 @@ public function testDate32FromStringGetValue(): void public function testDate32FromDateTimeFormatsAsYmd(): void { $dateTime = new DateTimeImmutable('2024-06-30 23:59:59'); - $date = Date32::fromDateTime($dateTime); + $date = Date32::fromDateTime($dateTime); self::assertSame('2024-06-30', $date->getValue()); } diff --git a/todo.md b/todo.md index a295f21..2ef3aa3 100644 --- a/todo.md +++ b/todo.md @@ -38,16 +38,17 @@ Extend `ValueFormatter` and add a type system for native parameters. - `ValueFormatter`: int, float, bool, string, null, DateTimeInterface, Expression, Type - String types `String` (`StringType`), `FixedString(N)`, the dates `Date`, `Date32`, `DateTime`, `DateTime64`, plus `UUID`, `IPv4`, `IPv6`, `Enum8`, `Enum16` are implemented - Strings and dates are covered by unit tests and by integration tests for CH 21 and CH 26 +- Numeric scalar types `Int8`–`Int256`, `UInt8`–`UInt256`, `Float32`/`Float64`, `Decimal32`–`Decimal256` are implemented (`Bool` is exposed via the existing `Boolean` class) - The remaining type work is listed in the phases below ### Plan, Phase 1: Core types -- [ ] `src/Type/`: extend the type system: - - [ ] `Int8`, `Int16`, `Int32`, `Int64`, `Int128`, `Int256` - - [ ] `UInt8`, `UInt16`, `UInt32`, `UInt64` (already exists), `UInt128`, `UInt256` - - [ ] `Float32`, `Float64` - - [ ] `Decimal(P, S)`, `Decimal32`, `Decimal64`, `Decimal128`, `Decimal256` - - [ ] `Bool` -- [ ] Tests for each type: insert + select + comparison +- [x] `src/Type/`: extend the type system: + - [x] `Int8`, `Int16`, `Int32`, `Int64`, `Int128`, `Int256` + - [x] `UInt8`, `UInt16`, `UInt32`, `UInt64` (already exists), `UInt128`, `UInt256` + - [x] `Float32`, `Float64` + - [x] `Decimal(P, S)`, `Decimal32`, `Decimal64`, `Decimal128`, `Decimal256` + - [x] `Bool` (exposed as the existing `Boolean` class; `Bool` is reserved by PHP) +- [x] Tests for each type: insert + select + comparison ### Plan, Phase 3: Composite types - [ ] `Array(T)`: already partially works, formalize it @@ -203,7 +204,6 @@ $db->select('SELECT 1'); | 3 | Structured exceptions | Low | None | **P0** | | 1 | Native Query Parameters | Medium | None (new methods) | **P1** | | 4 | PHPStan level max | Medium | None | **P1** | -| 2 | 60+ types (phase 1) | Medium | None | **P2** | | 2 | 60+ types (phases 3-4) | High | None | **P3** | ## Constraints