diff --git a/CHANGES.md b/CHANGES.md index eb24d99..85c85bb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,237 @@ # noneDB Changelog +## v3.1.0 (2025-12-29) + +### Major: Spatial Indexing + MongoDB-Style Comparison Operators + +This release introduces **R-tree spatial indexing** for geospatial queries and **MongoDB-style comparison operators** for advanced filtering. + +--- + +### Part 1: R-tree Spatial Indexing + +#### GeoJSON Support + +Full GeoJSON geometry support: +- **Point** - Single location `[lon, lat]` +- **LineString** - Path/route +- **Polygon** - Area with boundary (supports holes) +- **MultiPoint**, **MultiLineString**, **MultiPolygon** +- **GeometryCollection** + +```php +// Insert GeoJSON data +$db->insert("restaurants", [ + 'name' => 'Ottoman Kitchen', + 'location' => [ + 'type' => 'Point', + 'coordinates' => [28.9784, 41.0082] // [longitude, latitude] + ] +]); +``` + +#### Spatial Index Management + +```php +// Create R-tree index +$db->createSpatialIndex("restaurants", "location"); +// Returns: ["success" => true, "indexed" => 150] + +// Check if index exists +$db->hasSpatialIndex("restaurants", "location"); // true/false + +// List all spatial indexes +$db->getSpatialIndexes("restaurants"); // ["location"] + +// Drop index +$db->dropSpatialIndex("restaurants", "location"); + +// Rebuild index +$db->rebuildSpatialIndex("restaurants", "location"); +``` + +#### Spatial Query Methods + +All distance parameters and results are in **meters**. + +```php +// Find within radius +$nearby = $db->withinDistance("restaurants", "location", 28.97, 41.00, 5000); // 5000 meters + +// Find in bounding box +$inArea = $db->withinBBox("restaurants", "location", 28.97, 41.00, 29.00, 41.03); + +// Find K nearest +$closest = $db->nearest("restaurants", "location", 28.97, 41.00, 10); + +// Find within polygon +$inPolygon = $db->withinPolygon("restaurants", "location", $polygon); +``` + +#### Query Builder Integration + +```php +$results = $db->query("restaurants") + ->withinDistance('location', 28.97, 41.00, 5000) // 5000 meters + ->where(['open_now' => true]) + ->withDistance('location', 28.97, 41.00) // _distance field in meters + ->sort('_distance', 'asc') + ->limit(10) + ->get(); +``` + +#### Spatial Index File Structure + +``` +hash-dbname.nonedb.sidx.location # R-tree spatial index +hash-dbname.nonedb.gsidx.location # Global spatial index (sharded) +``` + +--- + +### Part 2: MongoDB-Style Comparison Operators + +#### Operator Reference + +| Operator | Description | Example | +|----------|-------------|---------| +| `$gt` | Greater than | `['age' => ['$gt' => 18]]` | +| `$gte` | Greater than or equal | `['price' => ['$gte' => 100]]` | +| `$lt` | Less than | `['stock' => ['$lt' => 10]]` | +| `$lte` | Less than or equal | `['rating' => ['$lte' => 5]]` | +| `$eq` | Equal (explicit) | `['status' => ['$eq' => 'active']]` | +| `$ne` | Not equal | `['role' => ['$ne' => 'guest']]` | +| `$in` | Value in array | `['category' => ['$in' => ['a', 'b']]]` | +| `$nin` | Value not in array | `['tag' => ['$nin' => ['spam']]]` | +| `$exists` | Field exists | `['email' => ['$exists' => true]]` | +| `$like` | Pattern match | `['name' => ['$like' => '^John']]` | +| `$regex` | Regular expression | `['email' => ['$regex' => '@gmail.com$']]` | +| `$contains` | Array/string contains | `['tags' => ['$contains' => 'featured']]` | + +#### Usage Examples + +```php +// Range query +$results = $db->query("products") + ->where([ + 'price' => ['$gte' => 100, '$lte' => 500], + 'stock' => ['$gt' => 0] + ]) + ->get(); + +// Multiple operators +$results = $db->query("users") + ->where([ + 'role' => ['$in' => ['admin', 'moderator']], + 'status' => ['$ne' => 'banned'], + 'email' => ['$exists' => true] + ]) + ->get(); + +// Combined with spatial queries +$results = $db->query("restaurants") + ->withinDistance('location', 28.97, 41.00, 5000) // 5000 meters + ->where([ + 'rating' => ['$gte' => 4.0], + 'cuisine' => ['$in' => ['turkish', 'italian']] + ]) + ->get(); +``` + +--- + +### Part 3: R-tree Performance Optimizations + +| Optimization | Description | +|--------------|-------------| +| **Parent Pointer Map** | O(1) parent lookup instead of O(n) tree scan | +| **Linear Split Algorithm** | O(n) seed selection instead of O(n²) quadratic split | +| **Dirty Flag Pattern** | Single disk write per batch instead of n writes | +| **Distance Memoization** | Cached Haversine distance calculations | +| **Centroid Caching** | Cached geometry centroid calculations | +| **Node Size 32** | Fewer tree levels and splits (increased from 16) | +| **Adaptive nearest()** | Exponential radius expansion for efficient k-NN | + +#### Performance Results + +| Operation | 100 | 1K | 5K | +|-----------|-----|-----|-----| +| createSpatialIndex | 2.4 ms | 81 ms | 423 ms | +| withinDistance (10km) | 3 ms | 32 ms | 166 ms | +| withinBBox | 0.7 ms | 7 ms | 38 ms | +| nearest(10) | 2 ms | 2 ms | 2.4 ms | + +#### Comparison Operator Performance + +| Operator | 100 | 1K | 5K | +|----------|-----|-----|-----| +| `$gt`, `$gte`, `$lt`, `$lte` | 0.7 ms | 6-7 ms | 33-38 ms | +| `$in`, `$nin` | 0.7 ms | 6.5-7 ms | 36-37 ms | +| `$like`, `$regex` | 0.7 ms | 7 ms | 39 ms | +| Complex (4 operators) | 0.7 ms | 7.5 ms | 43 ms | + +--- + +### New Methods + +#### Spatial Index Methods (noneDB) +- `createSpatialIndex($dbname, $field)` - Create R-tree index +- `hasSpatialIndex($dbname, $field)` - Check if index exists +- `getSpatialIndexes($dbname)` - List all spatial indexes +- `dropSpatialIndex($dbname, $field)` - Remove index +- `rebuildSpatialIndex($dbname, $field)` - Rebuild index +- `withinDistance($dbname, $field, $lon, $lat, $meters)` - Find within radius (meters) +- `withinBBox($dbname, $field, $minLon, $minLat, $maxLon, $maxLat)` - Find in bbox +- `nearest($dbname, $field, $lon, $lat, $k)` - Find K nearest +- `withinPolygon($dbname, $field, $polygon)` - Find in polygon +- `validateGeoJSON($geometry)` - Validate GeoJSON + +#### Query Builder Methods (noneDBQuery) +- `withinDistance($field, $lon, $lat, $meters)` - Spatial: within radius (meters) +- `withinBBox($field, $minLon, $minLat, $maxLon, $maxLat)` - Spatial: within bbox +- `nearest($field, $lon, $lat, $k)` - Spatial: K nearest +- `withinPolygon($field, $polygon)` - Spatial: within polygon +- `withDistance($field, $lon, $lat)` - Add `_distance` field to results (meters) + +#### Comparison Operators in where() +- `$gt`, `$gte`, `$lt`, `$lte` - Numeric comparisons +- `$eq`, `$ne` - Equality comparisons +- `$in`, `$nin` - Array membership +- `$exists` - Field existence +- `$like` - Pattern matching (case-insensitive) +- `$regex` - Regular expression matching +- `$contains` - Array/string contains + +--- + +### Test Results + +- **970 tests, 3079 assertions** (all passing) +- 42 new comparison operator tests +- 24 new spatial + operator combination tests +- 48 new query documentation tests +- Full sharded spatial index support verified + +### Documentation + +New documentation files in `docs/`: +- `QUERY.md` - Complete query builder reference +- `SPATIAL.md` - Spatial indexing guide +- `CONFIGURATION.md` - Configuration options +- `API.md` - Complete API reference +- `BENCHMARKS.md` - Performance benchmarks + +### Breaking Changes + +None. Spatial indexing is a new feature in v3.1.0. + +**Note:** All spatial distance parameters and results use **meters** as the unit: +- `withinDistance()` - distance parameter in meters +- `_distance` field in results is in meters +- `nearest()` `maxDistance` option is in meters + +--- + ## v3.0.0 (2025-12-28) ### Major: Pure JSONL Storage Engine + Maximum Performance Optimizations diff --git a/README.md b/README.md index e52e025..ba82563 100755 --- a/README.md +++ b/README.md @@ -1,26 +1,23 @@ # noneDB -[![Version](https://img.shields.io/badge/version-3.0.0-orange.svg)](CHANGES.md) +[![Version](https://img.shields.io/badge/version-3.1.0-orange.svg)](CHANGES.md) [![PHP Version](https://img.shields.io/badge/PHP-7.4%2B-blue.svg)](https://php.net) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-774%20passed-brightgreen.svg)](tests/) -[![Thread Safe](https://img.shields.io/badge/thread--safe-atomic%20locking-success.svg)](#concurrent-access--atomic-operations) +[![Tests](https://img.shields.io/badge/tests-970%20passed-brightgreen.svg)](tests/) +[![Thread Safe](https://img.shields.io/badge/thread--safe-atomic%20locking-success.svg)](#concurrent-access) **noneDB** is a lightweight, file-based NoSQL database for PHP. No installation required - just include and go! ## Features -- **Zero dependencies** - single PHP file (~6200 lines) +- **Zero dependencies** - single PHP file - **No database server required** - just include and use -- **JSONL storage with byte-offset indexing** - O(1) key lookups -- **Static cache sharing** - cross-instance cache for maximum performance -- **Atomic file locking** - thread-safe concurrent operations -- **Auto-compaction** - automatic cleanup of deleted records -- **Auto-sharding** for large datasets (500K+ records tested) -- **Method chaining** (fluent interface) for clean queries -- Full CRUD operations with advanced filtering -- Aggregation functions (sum, avg, min, max, count, distinct) -- Full-text search, pattern matching, range queries +- **O(1) key lookups** - JSONL storage with byte-offset indexing +- **Spatial indexing** - R-tree for geospatial queries (v3.1) +- **MongoDB-style operators** - `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$in`, `$nin`, `$exists`, `$like`, `$regex`, `$contains` (v3.1) +- **Auto-sharding** for large datasets (500K+ tested) +- **Thread-safe** - atomic file locking for concurrent access +- **Method chaining** - fluent query builder interface ## Requirements @@ -42,136 +39,6 @@ composer require orhanayd/nonedb --- -## Upgrading - -> **CRITICAL: Before updating noneDB, you MUST backup your `secretKey`!** - -The `secretKey` is used to hash database filenames. If you lose it or it changes, you will **lose access to all your existing data**. - -### Upgrade Steps (v3.0+) - -With the new config file system, upgrading is safer: - -1. **First time:** Create a `.nonedb` config file with your settings - ```bash - cp .nonedb.example .nonedb - # Edit .nonedb with your secretKey and other settings - ``` - -2. **Future updates:** Simply replace `noneDB.php` - your config is separate! - -3. **Verify:** Test that your databases are accessible - -### Upgrading from v2.x - -If you were storing `secretKey` directly in `noneDB.php`: - -1. **Before update:** Copy your current `secretKey` from `noneDB.php` -2. **Create config file:** Put it in `.nonedb`: - ```json - { - "secretKey": "your_current_key", - "dbDir": "./db/" - } - ``` -3. **Update:** Replace `noneDB.php` with the new version -4. **Verify:** Test that your databases are accessible - -> **Warning:** Never change your `secretKey` after creating data or you'll lose access to it. - ---- - -## Configuration - -> **IMPORTANT: Change these settings before production use!** - -### Config File (Recommended) - -Create a `.nonedb` file in your project root: - -```json -{ - "secretKey": "YOUR_SECURE_RANDOM_STRING", - "dbDir": "./db/", - "autoCreateDB": true, - "shardingEnabled": true, - "shardSize": 10000, - "autoMigrate": true, - "autoCompactThreshold": 0.3, - "lockTimeout": 5, - "lockRetryDelay": 10000 -} -``` - -A template is provided in `.nonedb.example`. Copy and customize: - -```bash -cp .nonedb.example .nonedb -# Edit .nonedb with your settings -``` - -> **Important:** Add `.nonedb` to your `.gitignore` to keep your secret key private! - -### Programmatic Configuration - -You can also pass configuration as an array: - -```php -$db = new noneDB([ - 'secretKey' => 'your_secure_key', - 'dbDir' => '/path/to/db/', - 'autoCreateDB' => true -]); -``` - -### Development Mode - -In development, you can skip requiring a config file by enabling dev mode: - -```php -// Option 1: Environment variable -putenv('NONEDB_DEV_MODE=1'); - -// Option 2: Constant -define('NONEDB_DEV_MODE', true); - -// Option 3: Static method -noneDB::setDevMode(true); -``` - -> **Warning:** Never enable dev mode in production! - -### Configuration Options - -| Option | Default | Description | -|--------|---------|-------------| -| `secretKey` | (required) | Secret key for hashing database names | -| `dbDir` | `./db/` | Database directory path | -| `autoCreateDB` | `true` | Auto-create databases on first use | -| `shardingEnabled` | `true` | Enable auto-sharding for large datasets | -| `shardSize` | `10000` | Records per shard | -| `autoMigrate` | `true` | Auto-migrate when threshold reached | -| `autoCompactThreshold` | `0.3` | Compact when 30% of records are deleted | -| `lockTimeout` | `5` | File lock timeout in seconds | -| `lockRetryDelay` | `10000` | Lock retry delay in microseconds | - -### Security Warnings - -| Setting | Warning | -|---------|---------| -| `secretKey` | **MUST change before production!** Used for hashing database names. Never share or commit to public repos. | -| `dbDir` | Should be outside web root or protected with `.htaccess` | -| `autoCreateDB` | Set to `false` in production to prevent accidental database creation | - -### Protecting Database Directory - -Create `db/.htaccess`: -```apache -Deny from all -``` - ---- - ## Quick Start ```php @@ -180,11 +47,21 @@ include("noneDB.php"); $db = new noneDB(); // Insert -$db->insert("users", ["name" => "John", "email" => "john@example.com"]); +$db->insert("users", ["name" => "John", "age" => 25, "email" => "john@example.com"]); // Find $users = $db->find("users", ["name" => "John"]); +// Query Builder with Operators +$results = $db->query("users") + ->where([ + 'age' => ['$gte' => 18, '$lte' => 65], + 'status' => 'active' + ]) + ->sort('age', 'desc') + ->limit(10) + ->get(); + // Update $db->update("users", [ ["name" => "John"], @@ -197,1116 +74,159 @@ $db->delete("users", ["name" => "John"]); --- -## API Reference - -### insert($dbname, $data) - -Insert one or more records. - -```php -// Single record -$result = $db->insert("users", [ - "name" => "John", - "email" => "john@example.com" -]); -// Returns: ["n" => 1] - -// Multiple records -$result = $db->insert("users", [ - ["name" => "John", "email" => "john@example.com"], - ["name" => "Jane", "email" => "jane@example.com"] -]); -// Returns: ["n" => 2] - -// Nested data is supported -$result = $db->insert("users", [ - "name" => "John", - "address" => [ - "city" => "Istanbul", - "country" => "Turkey" - ] -]); -``` - -> **Warning:** Field name `key` is reserved at the top level. You cannot use `["key" => "value"]` but nested `["data" => ["key" => "value"]]` is allowed. - ---- - -### find($dbname, $filter) - -Find records matching filter criteria. - -```php -// Get ALL records -$all = $db->find("users", 0); -// or -$all = $db->find("users", []); - -// Find by field value -$result = $db->find("users", ["name" => "John"]); - -// Find by multiple fields (AND condition) -$result = $db->find("users", ["name" => "John", "status" => "active"]); +## Configuration -// Find by key (index) -$result = $db->find("users", ["key" => 0]); // Single key -$result = $db->find("users", ["key" => [0, 2, 5]]); // Multiple keys -``` +Create a `.nonedb` file in your project root: -**Response:** -```php -[ - ["name" => "John", "email" => "john@example.com", "key" => 0], - ["name" => "Jane", "email" => "jane@example.com", "key" => 1] -] +```json +{ + "secretKey": "YOUR_SECURE_RANDOM_STRING", + "dbDir": "./db/", + "autoCreateDB": true +} ``` -> **Note:** Each result includes a `key` field with the record's index. - ---- - -### update($dbname, $data) - -Update records matching criteria. +**Or programmatically:** ```php -// Update by field -$result = $db->update("users", [ - ["name" => "John"], // Filter - ["set" => ["email" => "new@email.com"]] // New values -]); -// Returns: ["n" => 1] (number of updated records) - -// Update by key -$result = $db->update("users", [ - ["key" => [0, 1, 2]], - ["set" => ["status" => "inactive"]] -]); - -// Add new field to existing records -$result = $db->update("users", [ - ["name" => "John"], - ["set" => ["phone" => "555-1234"]] -]); - -// Update ALL records -$result = $db->update("users", [ - [], // Empty filter = all records - ["set" => ["updated_at" => time()]] +$db = new noneDB([ + 'secretKey' => 'your_secure_key', + 'dbDir' => '/path/to/db/' ]); ``` -> **Warning:** You cannot set `key` field in update - it's reserved. - ---- - -### delete($dbname, $filter) - -Delete records matching criteria. - -```php -// Delete by field -$result = $db->delete("users", ["name" => "John"]); -// Returns: ["n" => 1] - -// Delete by key -$result = $db->delete("users", ["key" => [0, 2]]); - -// Delete ALL records -$result = $db->delete("users", []); -``` - -> **Note:** Deleted records are immediately removed from the index. Data stays in file until auto-compaction triggers (when deleted > 30%). - ---- - -### createDB($dbname) - -Manually create a database. - -```php -$result = $db->createDB("mydb"); -// Returns: true (success) or false (already exists) -``` - ---- - -### checkDB($dbname) - -Check if database exists. Creates it if `autoCreateDB` is `true`. - -```php -$exists = $db->checkDB("mydb"); -// Returns: true or false -``` - ---- - -### getDBs($info) - -List databases. - -```php -// Get database names only -$names = $db->getDBs(false); -// Returns: ["users", "posts", "comments"] - -// Get databases with metadata -$dbs = $db->getDBs(true); -// Returns: -// [ -// ["name" => "users", "createdTime" => 1703123456, "size" => "2,5 KB"], -// ["name" => "posts", "createdTime" => 1703123789, "size" => "1,2 KB"] -// ] - -// Get specific database info -$info = $db->getDBs("users"); -// Returns: ["name" => "users", "createdTime" => 1703123456, "size" => "2,5 KB"] -``` - ---- - -### limit($array, $count) - -Limit results. - -```php -$all = $db->find("users", 0); -$first10 = $db->limit($all, 10); -``` - ---- - -### sort($array, $field, $order) - -Sort results by field. - -```php -$users = $db->find("users", 0); -$sorted = $db->sort($users, "age", "asc"); // Ascending -$sorted = $db->sort($users, "name", "desc"); // Descending -``` - ---- - -### count($dbname, $filter) - -Count records matching filter. - -```php -$total = $db->count("users", 0); // All records -$active = $db->count("users", ["active" => true]); // Filtered count -``` - ---- - -### distinct($dbname, $field) - -Get unique values for a field. - -```php -$cities = $db->distinct("users", "city"); -// Returns: ["Istanbul", "Ankara", "Izmir"] -``` - ---- - -### like($dbname, $field, $pattern) - -Pattern matching search. - -```php -$db->like("users", "email", "gmail"); // Contains "gmail" -$db->like("users", "name", "^John"); // Starts with "John" -$db->like("users", "name", "son$"); // Ends with "son" -``` - ---- - -### between($dbname, $field, $min, $max, $filter) - -Range query. - -```php -$products = $db->between("products", "price", 100, 500); -$active = $db->between("products", "price", 100, 500, ["active" => true]); -``` - ---- - -### sum($dbname, $field, $filter) / avg($dbname, $field, $filter) - -Aggregation functions. - -```php -$total = $db->sum("orders", "amount"); -$average = $db->avg("users", "age"); -$filtered = $db->sum("orders", "amount", ["status" => "paid"]); -``` - ---- - -### min($dbname, $field, $filter) / max($dbname, $field, $filter) - -Get minimum/maximum values. - -```php -$cheapest = $db->min("products", "price"); -$mostExpensive = $db->max("products", "price"); -``` - ---- - -### first($dbname, $filter) / last($dbname, $filter) - -Get first or last matching record. +**Development mode (no config required):** ```php -$firstUser = $db->first("users"); -$lastOrder = $db->last("orders", ["user_id" => 5]); +noneDB::setDevMode(true); +$db = new noneDB(); ``` ---- - -### exists($dbname, $filter) - -Check if records exist. - -```php -if ($db->exists("users", ["email" => "john@test.com"])) { - echo "User exists!"; -} -``` +> See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for all options. --- -## Method Chaining (Fluent Interface) - -noneDB supports fluent method chaining for building complex queries with clean, readable syntax. - -### Basic Usage - -```php -// Old API (still works) -$results = $db->find("users", ["active" => true]); -$sorted = $db->sort($results, "score", "desc"); -$limited = $db->limit($sorted, 10); - -// New Fluent API -$results = $db->query("users") - ->where(["active" => true]) - ->sort("score", "desc") - ->limit(10) - ->get(); -``` - -### Chainable Methods - -#### Basic Filters - -| Method | Description | Example | -|--------|-------------|---------| -| `where($filters)` | Filter by field values (AND) | `->where(["active" => true])` | -| `orWhere($filters)` | OR condition filter | `->orWhere(["role" => "admin"])` | -| `whereIn($field, $values)` | Field value in array | `->whereIn("status", ["active", "pending"])` | -| `whereNotIn($field, $values)` | Field value NOT in array | `->whereNotIn("role", ["banned", "suspended"])` | -| `whereNot($filters)` | NOT equal filter | `->whereNot(["deleted" => true])` | - -#### Pattern & Range Filters - -| Method | Description | Example | -|--------|-------------|---------| -| `like($field, $pattern)` | Pattern match (^start, end$) | `->like("email", "gmail")` | -| `notLike($field, $pattern)` | Pattern NOT match | `->notLike("email", "test")` | -| `between($field, $min, $max)` | Range filter (inclusive) | `->between("age", 18, 65)` | -| `notBetween($field, $min, $max)` | Outside range | `->notBetween("price", 100, 500)` | - -#### Advanced Filters - -| Method | Description | Example | -|--------|-------------|---------| -| `search($term, $fields)` | Full-text search | `->search("john", ["name", "email"])` | -| `join($db, $localKey, $foreignKey)` | Join with another database | `->join("orders", "id", "user_id")` | - -#### Grouping & Aggregation - -| Method | Description | Example | -|--------|-------------|---------| -| `groupBy($field)` | Group results by field | `->groupBy("category")` | -| `having($aggregate, $op, $value)` | Filter groups | `->having("count", ">", 5)` | - -#### Field Selection - -| Method | Description | Example | -|--------|-------------|---------| -| `select($fields)` | Include only specific fields | `->select(["name", "email"])` | -| `except($fields)` | Exclude specific fields | `->except(["password", "token"])` | - -#### Sorting & Pagination - -| Method | Description | Example | -|--------|-------------|---------| -| `sort($field, $order)` | Sort results | `->sort("created_at", "desc")` | -| `orderBy($field, $order)` | Alias for sort | `->orderBy("name", "asc")` | -| `limit($count)` | Limit results | `->limit(10)` | -| `offset($count)` | Skip results | `->offset(20)` | -| `skip($count)` | Alias for offset | `->skip(20)` | - -### Terminal Methods - -| Method | Returns | Description | -|--------|---------|-------------| -| `get()` | `array` | All matching records | -| `first()` | `?array` | First record or null | -| `last()` | `?array` | Last record or null | -| `count()` | `int` | Number of matches | -| `exists()` | `bool` | True if any match | -| `sum($field)` | `float` | Sum of field values | -| `avg($field)` | `float` | Average of field | -| `min($field)` | `mixed` | Minimum value | -| `max($field)` | `mixed` | Maximum value | -| `distinct($field)` | `array` | Unique values | -| `update($set)` | `array` | Update matching records | -| `delete()` | `array` | Delete matching records | -| `removeFields($fields)` | `array` | Remove fields permanently | - -### Examples +## Query Builder ```php -// Complex query with multiple filters -$topUsers = $db->query("users") - ->where(["active" => true]) - ->whereIn("role", ["admin", "moderator"]) - ->between("age", 18, 35) - ->like("email", "gmail.com$") - ->sort("score", "desc") - ->limit(10) +// Comparison operators +$db->query("products") + ->where([ + 'price' => ['$gte' => 100, '$lte' => 500], + 'category' => ['$in' => ['electronics', 'gadgets']], + 'stock' => ['$gt' => 0] + ]) + ->sort('rating', 'desc') + ->limit(20) ->get(); -// OR conditions -$users = $db->query("users") - ->where(["department" => "IT"]) - ->orWhere(["department" => "Engineering"]) - ->orWhere(["role" => "admin"]) - ->get(); - -// Full-text search -$results = $db->query("products") - ->search("wireless keyboard") - ->sort("price", "asc") - ->get(); - -// Join databases -$orders = $db->query("orders") - ->where(["status" => "completed"]) - ->join("users", "user_id", "id") - ->get(); -// Each order now has a "users" field with the joined user data - -// Group by with having -$categories = $db->query("products") - ->groupBy("category") - ->having("count", ">", 10) - ->having("avg:price", ">", 100) - ->get(); - -// Select specific fields only -$users = $db->query("users") - ->select(["name", "email", "avatar"]) - ->limit(50) - ->get(); - -// Exclude sensitive fields -$users = $db->query("users") - ->except(["password", "token", "secret_key"]) +// Pattern matching +$db->query("users") + ->where(['email' => ['$like' => 'gmail.com$']]) ->get(); -// Aggregation -$avgSalary = $db->query("employees") - ->where(["department" => "Engineering"]) - ->avg("salary"); - // Existence check -if ($db->query("users")->where(["email" => $email])->exists()) { - echo "Email already registered!"; -} - -// Update with chain -$db->query("users") - ->where(["status" => "pending"]) - ->whereIn("created_at", $oldDates) - ->update(["status" => "expired"]); - -// Delete with chain -$db->query("logs") - ->where(["level" => "debug"]) - ->notBetween("created_at", $startDate, $endDate) - ->delete(); - -// Remove fields permanently $db->query("users") - ->where(["status" => "deleted"]) - ->removeFields(["personal_data", "payment_info"]); - -// Pagination -$page = 2; -$perPage = 20; -$users = $db->query("users") - ->sort("created_at", "desc") - ->limit($perPage) - ->skip(($page - 1) * $perPage) + ->where(['phone' => ['$exists' => true]]) ->get(); ``` ---- - -## Auto-Sharding - -noneDB automatically partitions large databases into smaller shards for better performance. When a database reaches the threshold (default: 10,000 records), it's automatically split into multiple shard files. - -### How It Works - -``` -Without Sharding (50K records): -├── hash-users.nonedb # 5 MB, entire file read for filter operations -├── hash-users.nonedb.jidx # Index file for O(1) key lookups - -With Sharding (50K records, 5 shards): -├── hash-users.nonedb.meta # Shard metadata -├── hash-users_s0.nonedb # Shard 0: records 0-9,999 -├── hash-users_s0.nonedb.jidx # Shard 0 index -├── hash-users_s1.nonedb # Shard 1: records 10,000-19,999 -├── hash-users_s1.nonedb.jidx # Shard 1 index -├── ... -└── hash-users_s4.nonedb # Shard 4: records 40,000-49,999 -``` +### Available Operators -### Performance Characteristics (50K Records, 5 Shards) +| Operator | Description | Example | +|----------|-------------|---------| +| `$gt` | Greater than | `['age' => ['$gt' => 18]]` | +| `$gte` | Greater than or equal | `['price' => ['$gte' => 100]]` | +| `$lt` | Less than | `['stock' => ['$lt' => 10]]` | +| `$lte` | Less than or equal | `['rating' => ['$lte' => 5]]` | +| `$ne` | Not equal | `['role' => ['$ne' => 'guest']]` | +| `$in` | In array | `['category' => ['$in' => ['a', 'b']]]` | +| `$nin` | Not in array | `['tag' => ['$nin' => ['spam']]]` | +| `$exists` | Field exists | `['email' => ['$exists' => true]]` | +| `$like` | Pattern match | `['name' => ['$like' => '^John']]` | +| `$regex` | Regex match | `['email' => ['$regex' => '@gmail']]` | +| `$contains` | Array/string contains | `['tags' => ['$contains' => 'featured']]` | -| Operation | Cold (first access) | Warm (cached) | Notes | -|-----------|---------------------|---------------|-------| -| **find(key)** | ~66 ms | **~0.05 ms** | O(1) byte-offset lookup | -| **find(filter)** | ~219 ms | ~200 ms | Scans all shards | -| **update** | ~148 ms | ~140 ms | Only modifies target shard | -| **insert** | ~704 ms | - | Distributes across shards | - -> **Key Benefit:** With O(1) byte-offset indexing, key lookups are near-instant after cache warm-up. Filter operations scan all shards but each shard file is smaller. - -### Sharding API - -#### getShardInfo($dbname) - -Get sharding information for a database. - -```php -$info = $db->getShardInfo("users"); -// Returns: -// [ -// "sharded" => true, -// "shards" => 5, -// "totalRecords" => 500000, -// "deletedCount" => 150, -// "shardSize" => 100000, -// "nextKey" => 500150 -// ] - -// For non-sharded database: -// ["sharded" => false, "shards" => 0, "totalRecords" => 50000, "shardSize" => 100000] -``` - -#### compact($dbname) - -Remove deleted records and reclaim space. Works for both sharded and non-sharded databases. - -```php -$result = $db->compact("users"); - -// Sharded database: -// [ -// "success" => true, -// "freedSlots" => 1500, -// "newShardCount" => 48, -// "sharded" => true -// ] - -// Non-sharded database: -// [ -// "success" => true, -// "freedSlots" => 50, -// "totalRecords" => 950, -// "sharded" => false -// ] - -// Error cases: -// ["success" => false, "status" => "database_not_found"] -// ["success" => false, "status" => "read_error"] -``` - -> **Note:** Auto-compaction runs automatically when deleted records exceed 30% of total. Manual compaction is optional but can be used to immediately reclaim disk space. - -#### migrate($dbname) - -Manually trigger migration to sharded format (normally happens automatically). - -```php -$result = $db->migrate("users"); -// Returns: -// ["success" => true, "status" => "migrated"] - Successfully migrated -// ["success" => true, "status" => "already_sharded"] - Already sharded, no action taken -// ["success" => false, "status" => "database_not_found"] - Database doesn't exist -// ["success" => false, "status" => "migration_failed"] - Migration error -``` - -#### isShardingEnabled() / getShardSize() - -Check current sharding configuration. - -```php -$db->isShardingEnabled(); // Returns: true -$db->getShardSize(); // Returns: 10000 -``` - -### Configuration Options - -```php -// Disable sharding entirely -private $shardingEnabled = false; - -// Change shard size (records per shard) -private $shardSize = 10000; // Default: 10K records per shard - -// Disable auto-migration (manual control) -private $autoMigrate = false; -``` - -### When to Use Sharding - -| Dataset Size | Recommendation | -|--------------|----------------| -| < 10K records | Sharding unnecessary | -| 10K - 500K | **Auto-sharding enabled (default)** | -| > 500K | Works well, tested up to 500K records | - -### Sharding Limitations - -- Filter-based queries still scan all shards -- Slightly slower for bulk inserts (writes to multiple files) -- More files to manage in the database directory -- Backup requires copying all shard files +> See [docs/QUERY.md](docs/QUERY.md) for complete query reference. --- -## JSONL Storage Engine - -noneDB v3.0 introduces a **pure JSONL storage format** with byte-offset indexing for O(1) key lookups. This replaces the previous JSON array format. - -### Storage Format - -**Database file (JSONL):** `hash-dbname.nonedb` -``` -{"key":0,"name":"John","email":"john@example.com"} -{"key":1,"name":"Jane","email":"jane@example.com"} -{"key":2,"name":"Bob","email":"bob@example.com"} -``` - -**Index file:** `hash-dbname.nonedb.jidx` -```json -{ - "v": 3, - "format": "jsonl", - "n": 3, - "d": 0, - "o": { - "0": [0, 52], - "1": [53, 52], - "2": [106, 50] - } -} -``` - -| Index Field | Description | -|-------------|-------------| -| `v` | Index version (3) | -| `format` | Storage format ("jsonl") | -| `n` | Next key counter | -| `d` | Dirty count (deleted records pending compaction) | -| `o` | Offset map: `{key: [byteOffset, length]}` | - -### Performance Improvements - -| Operation | Old (JSON) | New (JSONL) | Improvement | -|-----------|------------|-------------|-------------| -| Find by key | O(n) scan | **O(1) lookup** | **Instant** | -| Insert | O(n) read+write | **O(1) append** | **Constant time** | -| Update | O(n) read+write | **O(1) in-place** | **Constant time** | -| Delete | O(n) read+write | **O(1) mark** | **Constant time** | - -### Auto-Compaction - -Deleted records are immediately removed from the index. The data stays in the file until auto-compaction triggers: - -- **Trigger:** When dirty records exceed 30% of total -- **Action:** Rewrites file removing stale data, updates all byte offsets -- **Result:** No manual intervention needed - -```php -// Manual compaction still available -$result = $db->compact("users"); -// ["success" => true, "freedSlots" => 15, "totalRecords" => 100] -``` - -### Static Cache - -Multiple noneDB instances share cache via static properties: - -```php -// Instances share index cache - no duplicate disk reads -$db1 = new noneDB(); -$db1->find("users", ['key' => 1]); // Loads index, caches statically - -$db2 = new noneDB(); -$db2->find("users", ['key' => 1]); // Uses cached index - instant! - -// Clear cache (useful for testing/benchmarking) -noneDB::clearStaticCache(); - -// Disable/enable static caching -noneDB::disableStaticCache(); -noneDB::enableStaticCache(); -``` - -### Migration from Previous Versions - -**Backwards Compatibility:** Databases created with any previous version (v1.x `{"data":[...]}` or v2.x JSON array format) are automatically migrated to the new JSONL format on first access. Your existing data is preserved - just upgrade and go. - -**How it works:** -1. Old format detected (`{"data": [...]}` or JSON array) -2. Records converted to JSONL (one per line) -3. Byte-offset index created (`.jidx` file) -4. Original file overwritten with JSONL content - -**No manual intervention required.** - ---- - -## Error Handling - -Operations return error information when they fail: +## Spatial Queries ```php -$result = $db->insert("users", "invalid"); -// Returns: ["n" => 0, "error" => "insert data must be array"] - -$result = $db->insert("users", ["key" => "value"]); -// Returns: ["n" => 0, "error" => "You cannot set key name to key"] - -$result = $db->update("users", "invalid"); -// Returns: ["n" => 0, "error" => "Please check your update parameters"] -``` +// Create spatial index +$db->createSpatialIndex("restaurants", "location"); ---- +// Insert GeoJSON data +$db->insert("restaurants", [ + 'name' => 'Ottoman Kitchen', + 'location' => ['type' => 'Point', 'coordinates' => [28.9784, 41.0082]] +]); -## Performance Benchmarks +// Find within radius +$nearby = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5000) // 5000 meters (5km) + ->where(['open_now' => true]) + ->get(); -Tested on PHP 8.2, macOS (Apple Silicon M-series) - **v3.0 JSONL Storage Engine** +// Find nearest K +$closest = $db->nearest("restaurants", "location", 28.9784, 41.0082, 10); -**Test data structure (7 fields per record):** -```php -[ - "name" => "User123", - "email" => "user123@test.com", - "age" => 25, - "salary" => 8500, - "city" => "Istanbul", - "department" => "IT", - "active" => true -] +// Find in bounding box +$inArea = $db->withinBBox("restaurants", "location", 28.97, 41.00, 29.00, 41.03); ``` -### v3.0 Optimizations - -| Optimization | Improvement | -|--------------|-------------| -| **Static Cache Sharing** | 80%+ for multi-instance | -| **Batch File Read** | 40-50% for bulk reads | -| **Batch Update/Delete** | **25-30x faster** for bulk operations | -| **Single-Pass Filtering** | 30% for complex queries | -| **O(1) Sharded Key Lookup** | True O(1) for all database sizes | -| **O(1) Count** | **100-330x faster** (index metadata lookup) | -| **Hash Cache Persistence** | Faster cold startup | -| **atomicReadFast()** | Optimized index reads | - -### O(1) Key Lookup (Warmed Cache) - -| Records | Cold | Warm | Notes | -|---------|------|------|-------| -| 100 | 3 ms | 0.03 ms | Non-sharded | -| 1K | 3 ms | 0.03 ms | Non-sharded | -| 10K | 49 ms | 0.03 ms | Sharded (1 shard) | -| 50K | 243 ms | 0.05 ms | Sharded (5 shards) | -| 100K | 497 ms | 0.05 ms | Sharded (10 shards) | -| 500K | 2.5 s | 0.16 ms | Sharded (50 shards) | - -> **Key lookups are O(1)** - constant time regardless of database size after cache warm-up! - -### Write Operations -| Operation | 100 | 1K | 10K | 50K | 100K | 500K | -|-----------|-----|-----|------|------|-------|-------| -| insert() | 7 ms | 25 ms | 289 ms | 1.5 s | 3.1 s | 16.5 s | -| update() | 1 ms | 11 ms | 120 ms | 660 ms | 1.5 s | 11.3 s | -| delete() | 2 ms | 13 ms | 144 ms | 773 ms | 1.7 s | 12.5 s | - -> Note: Update/delete use batch operations for efficient bulk modifications (single index write per shard) - -### Read Operations -| Operation | 100 | 1K | 10K | 50K | 100K | 500K | -|-----------|-----|-----|------|------|-------|-------| -| find(all) | 3 ms | 23 ms | 48 ms | 268 ms | 602 ms | 2.7 s | -| find(key) | <1 ms | <1 ms | 49 ms | 243 ms | 497 ms | 2.5 s | -| find(filter) | <1 ms | 4 ms | 50 ms | 252 ms | 515 ms | 2.6 s | - -> **find(key)** first call includes index loading. Subsequent calls: ~0.05ms (see O(1) table above) - -### Query & Aggregation -| Operation | 100 | 1K | 10K | 50K | 100K | 500K | -|-----------|-----|-----|------|------|-------|-------| -| count() | **<1 ms** | **<1 ms** | **<1 ms** | **<1 ms** | **<1 ms** | **<1 ms** | -| distinct() | <1 ms | 4 ms | 49 ms | 270 ms | 590 ms | 2.9 s | -| sum() | <1 ms | 4 ms | 49 ms | 261 ms | 588 ms | 3 s | -| like() | <1 ms | 5 ms | 57 ms | 311 ms | 670 ms | 3.4 s | -| between() | <1 ms | 4 ms | 53 ms | 288 ms | 628 ms | 3.2 s | -| sort() | <1 ms | 8 ms | 105 ms | 565 ms | 1.3 s | 7.1 s | -| first() | <1 ms | 4 ms | 50 ms | 285 ms | 589 ms | 2.9 s | -| exists() | <1 ms | 4 ms | 49 ms | 272 ms | 588 ms | 3 s | - -> **count()** now uses O(1) index metadata lookup - no record scanning required! - -### Method Chaining -| Operation | 100 | 1K | 10K | 50K | 100K | 500K | -|-----------|-----|-----|------|------|-------|-------| -| whereIn() | <1 ms | 4 ms | 53 ms | 302 ms | 657 ms | 3.6 s | -| orWhere() | <1 ms | 4 ms | 55 ms | 316 ms | 673 ms | 3.5 s | -| search() | <1 ms | 5 ms | 61 ms | 350 ms | 762 ms | 4.2 s | -| groupBy() | <1 ms | 4 ms | 52 ms | 307 ms | 657 ms | 3.5 s | -| select() | <1 ms | 5 ms | 57 ms | 400 ms | 854 ms | 4.5 s | -| complex chain | <1 ms | 5 ms | 60 ms | 322 ms | 684 ms | 3.6 s | - -> **Complex chain:** `where() + whereIn() + between() + select() + sort() + limit()` - -### Storage -| Records | File Size | Peak Memory | -|---------|-----------|-------------| -| 100 | 10 KB | 2 MB | -| 1,000 | 98 KB | 4 MB | -| 10,000 | 1 MB | 8 MB | -| 50,000 | 5 MB | 34 MB | -| 100,000 | 10 MB | 134 MB | -| 500,000 | 50 MB | ~600 MB | +> See [docs/SPATIAL.md](docs/SPATIAL.md) for complete spatial reference. --- -## SleekDB vs noneDB Comparison - -### Why Choose noneDB? - -noneDB v3.0 excels in **bulk operations** and **large datasets**: +## Documentation -| Strength | Performance | +| Document | Description | |----------|-------------| -| 🚀 **Bulk Insert** | **8-10x faster** than SleekDB | -| 🔍 **Find All** | **8-66x faster** at scale | -| 🎯 **Filter Queries** | **20-80x faster** at scale | -| ✏️ **Update Operations** | **15-40x faster** on large datasets | -| 🗑️ **Delete Operations** | **5-23x faster** on large datasets | -| 📊 **Count Operations** | **90-330x faster** (O(1) index lookup) | -| 🔗 **Complex Queries** | **22-70x faster** at scale | -| 📦 **Large Datasets** | Handles 500K+ records with auto-sharding | -| 🔒 **Thread Safety** | Atomic file locking for concurrent access | -| ⚡ **Static Cache** | Cross-instance cache sharing | - -**Best for:** Bulk operations, analytics, batch processing, filter-heavy workloads, count operations - -### When to Consider SleekDB? - -| Scenario | SleekDB Advantage | -|----------|-------------------| -| 🎯 **High-frequency key lookups** | <1ms vs ~500ms cold (file-per-record architecture) | -| 💾 **Very low memory** | Lower RAM usage | - -> **Note:** SleekDB stores each record as a separate file, making single-record lookups instant but bulk operations slow. -> -> **Update v3.0:** noneDB's count() is now **90-330x faster** than SleekDB using O(1) index metadata lookup! - ---- - -### Architectural Differences - -| Feature | SleekDB | noneDB | -|---------|---------|--------| -| **Storage** | One JSON file per record | JSONL + byte-offset index | -| **ID Access** | Direct file read (O(1)) | Index lookup + seek | -| **Bulk Read** | Traverse all files | Single file read | -| **Sharding** | None | Automatic (10K+) | -| **Cache** | Per-query | Static cross-instance | -| **Indexing** | None | Byte-offset (.jidx) | - ---- - -### Benchmark Results (v3.0) - -#### Bulk Insert -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | 7ms | 24ms | **noneDB 3x** | -| 1K | 26ms | 250ms | **noneDB 10x** | -| 10K | 306ms | 2.89s | **noneDB 9x** | -| 50K | 1.59s | 12.4s | **noneDB 8x** | -| 100K | 3.34s | 30.76s | **noneDB 9x** | - -#### Find All Records -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | 3ms | 28ms | **noneDB 8x** | -| 1K | 7ms | 286ms | **noneDB 42x** | -| 10K | 65ms | 2.71s | **noneDB 42x** | -| 50K | 300ms | 16.83s | **noneDB 56x** | -| 100K | 595ms | 39.03s | **noneDB 66x** | - -#### Find by Key (Single Record - Cold) -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | 3ms | <1ms | SleekDB | -| 1K | 3ms | <1ms | SleekDB | -| 10K | 55ms | <1ms | **SleekDB** | -| 50K | 287ms | <1ms | **SleekDB** | -| 100K | 561ms | <1ms | **SleekDB** | - -> **Note:** SleekDB's file-per-record design gives O(1) key lookup. noneDB must load shard index first (but subsequent lookups are O(1) with cache - see warmed cache table above). - -#### Find with Filter -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | <1ms | 10ms | **noneDB 24x** | -| 1K | 4ms | 94ms | **noneDB 25x** | -| 10K | 49ms | 998ms | **noneDB 20x** | -| 50K | 254ms | 13.18s | **noneDB 52x** | -| 100K | 524ms | 41.64s | **noneDB 79x** | - -#### Count Operations -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | <1ms | <1ms | **noneDB 4x** | -| 1K | <1ms | 1ms | **noneDB 11x** | -| 10K | <1ms | 9ms | **noneDB 90x** | -| 50K | <1ms | 51ms | **noneDB 330x** | -| 100K | <1ms | 96ms | **noneDB 258x** | - -> **v3.0 Optimization:** noneDB now uses O(1) index metadata lookup for count() - no record scanning! - -#### Update Operations -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | 1ms | 20ms | **noneDB 15x** | -| 1K | 11ms | 188ms | **noneDB 17x** | -| 10K | 118ms | 2.14s | **noneDB 18x** | -| 50K | 669ms | 20.91s | **noneDB 31x** | -| 100K | 1.53s | 61.27s | **noneDB 40x** | - -#### Delete Operations -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | 2ms | 10ms | **noneDB 5x** | -| 1K | 15ms | 105ms | **noneDB 7x** | -| 10K | 150ms | 1.27s | **noneDB 8x** | -| 50K | 839ms | 14.61s | **noneDB 17x** | -| 100K | 1.75s | 40.01s | **noneDB 23x** | - -#### Complex Query (where + sort + limit) -| Records | noneDB | SleekDB | Winner | -|---------|--------|---------|--------| -| 100 | <1ms | 12ms | **noneDB 27x** | -| 1K | 4ms | 114ms | **noneDB 30x** | -| 10K | 55ms | 1.2s | **noneDB 22x** | -| 50K | 295ms | 15.33s | **noneDB 52x** | -| 100K | 591ms | 41.3s | **noneDB 70x** | - ---- - -### Summary (v3.0) - -| Use Case | Winner | Advantage | -|----------|--------|-----------| -| **Bulk Insert** | **noneDB** | 3-10x faster | -| **Find All** | **noneDB** | 8-66x faster | -| **Find with Filter** | **noneDB** | 20-79x faster | -| **Update** | **noneDB** | 15-40x faster | -| **Delete** | **noneDB** | 5-23x faster | -| **Complex Query** | **noneDB** | 22-70x faster | -| **Count** | **noneDB** | 4-330x faster (O(1) index lookup) | -| **Find by Key (cold)** | **SleekDB** | O(1) file access | - -> **Choose noneDB** for: Bulk operations, large datasets, filter queries, update/delete workloads, complex queries, count operations -> -> **Choose SleekDB** for: High-frequency single-record lookups by ID (cold cache scenarios) - ---- - -## Concurrent Access & Atomic Operations - -noneDB v2.2 implements **professional-grade atomic file locking** using `flock()` to ensure thread-safe concurrent access: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Process A │ Process B │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. atomicModify() called │ │ -│ 2. flock(LOCK_EX) acquired │ 3. atomicModify() called │ -│ 4. Read data │ 5. flock() waits... │ -│ 6. Modify data │ (blocked) │ -│ 7. Write data │ │ -│ 8. flock(LOCK_UN) released │ │ -│ │ 9. flock(LOCK_EX) acquired │ -│ │ 10. Read data (sees A's changes) │ -│ │ 11. Modify & Write │ -│ │ 12. flock(LOCK_UN) released │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Atomic Operations Guarantee -- **No lost updates** - All concurrent writes are serialized -- **Read consistency** - Reads wait for ongoing writes to complete -- **Crash safety** - Uses `flock()` which is automatically released on process termination - -### Tested Scenarios -| Scenario | Result | -|----------|--------| -| 2 processes × 100 inserts | **200/200** records (100% success) | -| 5 processes × 50 inserts | **250/250** records (100% success) | -| Repeated stress tests | **0% data loss** across all runs | - ---- - -## Warnings & Limitations - -### Security -- **Change `$secretKey`** before deploying to production -- **Protect `db/` directory** from direct web access -- Sanitize user input before using as database names -- Database names are sanitized to `[A-Za-z0-9' -]` only - -### Performance Considerations -- Optimized for datasets up to 100,000 records per shard -- **With sharding:** Tested up to 500,000 records with excellent performance -- Filter-based queries scan all shards (linear complexity) -- Primary key index system for faster key lookups -- For full-table scans on 500K+ records, expect 6-8 second response times - -### Data Integrity -- No transactions support (each operation is atomic individually) -- No foreign key constraints -- **Concurrent writes are fully atomic** - no race conditions -- **Auto-compaction** - deleted records are automatically cleaned up when threshold reached - -### Character Encoding -- Database names: Only `A-Z`, `a-z`, `0-9`, space, hyphen, apostrophe allowed -- Other characters in names are silently removed -- Data content: Full UTF-8 support +| [docs/QUERY.md](docs/QUERY.md) | Query builder, operators, filters | +| [docs/SPATIAL.md](docs/SPATIAL.md) | Geospatial indexing and queries | +| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Configuration options | +| [docs/API.md](docs/API.md) | Complete API reference | --- -## Database Name Sanitization - -Database names are sanitized automatically: +## Performance -```php -$db->insert("my_database!", ["data" => "test"]); -// Actually creates/uses "mydatabase" (underscore and ! removed) +| Operation | 10K Records | 100K Records | +|-----------|-------------|--------------| +| find(key) | **0.03 ms** | **0.05 ms** | +| find(filter) | 50 ms | 520 ms | +| insert (batch) | 290 ms | 3.1 s | +| count() | **< 1 ms** | **< 1 ms** | +| withinDistance | 10-20 ms | 50-100 ms | -$db->insert("test-db", ["data" => "test"]); // OK - hyphen allowed -$db->insert("test db", ["data" => "test"]); // OK - space allowed -$db->insert("test'db", ["data" => "test"]); // OK - apostrophe allowed -``` +> Benchmarks on Apple Silicon. Key lookups are O(1) with byte-offset indexing. --- -## File Structure - -### Standard Database (< 10K records) -``` -project/ -├── noneDB.php -└── db/ - ├── a1b2c3...-users.nonedb # Database file (JSONL) - ├── a1b2c3...-users.nonedb.jidx # Byte-offset index - ├── a1b2c3...-users.nonedbinfo # Metadata (creation time) - ├── d4e5f6...-posts.nonedb - ├── d4e5f6...-posts.nonedb.jidx - └── d4e5f6...-posts.nonedbinfo -``` - -### Sharded Database (10K+ records) -``` -project/ -├── noneDB.php -└── db/ - ├── a1b2c3...-users.nonedb.meta # Shard metadata - ├── a1b2c3...-users_s0.nonedb # Shard 0 data (JSONL) - ├── a1b2c3...-users_s0.nonedb.jidx # Shard 0 index - ├── a1b2c3...-users_s1.nonedb # Shard 1 data (JSONL) - ├── a1b2c3...-users_s1.nonedb.jidx # Shard 1 index - ├── a1b2c3...-users_s2.nonedb # Shard 2 data (JSONL) - ├── a1b2c3...-users_s2.nonedb.jidx # Shard 2 index - └── a1b2c3...-users.nonedbinfo # Creation time -``` +## Testing -Database file format (JSONL - one record per line): -``` -{"key":0,"name":"John","email":"john@example.com"} -{"key":1,"name":"Jane","email":"jane@example.com"} -{"key":2,"name":"Bob","email":"bob@example.com"} -``` +```bash +# Run all tests +composer test -Index file format (`.jidx`): -```json -{ - "v": 3, - "format": "jsonl", - "n": 3, - "d": 0, - "o": {"0": [0, 52], "1": [53, 52], "2": [106, 50]} -} -``` +# Run with details +vendor/bin/phpunit --testdox -Shard metadata format (`.meta` file): -```json -{ - "version": 1, - "shardSize": 10000, - "totalRecords": 25000, - "deletedCount": 150, - "nextKey": 25150, - "shards": [ - {"id": 0, "file": "_s0", "count": 9850, "deleted": 150}, - {"id": 1, "file": "_s1", "count": 10000, "deleted": 0}, - {"id": 2, "file": "_s2", "count": 5000, "deleted": 0} - ] -} +# Run specific suite +vendor/bin/phpunit --testsuite Feature ``` --- -## Testing +## Concurrent Access -```bash -# Install dependencies -composer install - -# Run tests -vendor/bin/phpunit +noneDB uses atomic file locking (`flock()`) for thread-safe operations: -# Run with details -vendor/bin/phpunit --testdox -``` +- **No lost updates** - concurrent writes are serialized +- **Read consistency** - reads wait for ongoing writes +- **Crash safety** - locks auto-release on process termination --- @@ -1320,35 +240,6 @@ vendor/bin/phpunit --testdox --- -## Roadmap - -- [x] `distinct()` - Get unique values -- [x] `sort()` - Sort results -- [x] `count()` - Count records -- [x] `like()` - Pattern matching search -- [x] `sum()` / `avg()` - Aggregation functions -- [x] `min()` / `max()` - Min/Max values -- [x] `first()` / `last()` - First/Last record -- [x] `exists()` - Check if records exist -- [x] `between()` - Range queries -- [x] **Auto-sharding** - Horizontal partitioning for large datasets -- [x] `orWhere()` - OR condition queries -- [x] `whereIn()` / `whereNotIn()` - Array membership filters -- [x] `whereNot()` - Negation filters -- [x] `notLike()` / `notBetween()` - Negated pattern and range filters -- [x] `search()` - Full-text search -- [x] `join()` - Database joins -- [x] `groupBy()` / `having()` - Grouping and aggregate filtering -- [x] `select()` / `except()` - Field projection -- [x] `removeFields()` - Permanent field removal -- [x] **JSONL Storage Engine** - O(1) key lookups with byte-offset indexing (v3.0) -- [x] **Static Cache Sharing** - Cross-instance cache for 80%+ improvement (v3.0) -- [x] **Auto-Compaction** - Automatic cleanup when deleted > 30% (v3.0) -- [x] **Batch File Read** - 40-50% faster bulk reads (v3.0) -- [x] **Single-Pass Filtering** - 30% faster complex queries (v3.0) - ---- - ## License MIT License - see [LICENSE](LICENSE) file. @@ -1359,15 +250,10 @@ MIT License - see [LICENSE](LICENSE) file. **Orhan Aydogdu** - Website: [orhanaydogdu.com.tr](https://orhanaydogdu.com.tr) -- Email: info@orhanaydogdu.com.tr - GitHub: [@orhanayd](https://github.com/orhanayd) --- -**Free Software, Hell Yeah!** - ---- - > "Hayatta en hakiki mürşit ilimdir." > > "The truest guide in life is science." diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f9eada6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,472 @@ +# noneDB API Reference + +Complete reference for all noneDB methods and operations. + +**Version:** 3.1.0 + +--- + +## Table of Contents + +1. [Database Operations](#database-operations) +2. [CRUD Operations](#crud-operations) +3. [Query Operations](#query-operations) +4. [Aggregation](#aggregation) +5. [Sharding](#sharding) +6. [Field Indexing](#field-indexing) +7. [Spatial Indexing](#spatial-indexing) +8. [Utility Methods](#utility-methods) + +--- + +## Database Operations + +### createDB() + +Create a new database. + +```php +$result = $db->createDB("users"); +// Returns: true (created) or false (already exists) +``` + +### checkDB() + +Check if database exists. Creates it if `autoCreateDB` is true. + +```php +$exists = $db->checkDB("users"); +// Returns: true or false +``` + +### getDBs() + +List databases. + +```php +// Names only +$names = $db->getDBs(false); +// ["users", "posts", "comments"] + +// With metadata +$dbs = $db->getDBs(true); +// [ +// ["name" => "users", "createdTime" => 1703123456, "size" => "2,5 KB"], +// ... +// ] + +// Single database info +$info = $db->getDBs("users"); +// ["name" => "users", "createdTime" => 1703123456, "size" => "2,5 KB"] +``` + +--- + +## CRUD Operations + +### insert() + +Insert one or more records. + +```php +// Single record +$result = $db->insert("users", ["name" => "John", "email" => "john@test.com"]); +// ["n" => 1] + +// Multiple records +$result = $db->insert("users", [ + ["name" => "John"], + ["name" => "Jane"] +]); +// ["n" => 2] + +// With nested data +$result = $db->insert("users", [ + "name" => "John", + "address" => ["city" => "Istanbul", "country" => "Turkey"] +]); +``` + +**Errors:** + +```php +// Reserved field +$db->insert("users", ["key" => "value"]); +// ["n" => 0, "error" => "You cannot set key name to key"] + +// Invalid data +$db->insert("users", "not an array"); +// ["n" => 0, "error" => "insert data must be array"] +``` + +### find() + +Find records matching filter. + +```php +// All records +$all = $db->find("users", 0); +$all = $db->find("users", []); + +// By field +$results = $db->find("users", ["name" => "John"]); + +// By multiple fields (AND) +$results = $db->find("users", ["name" => "John", "active" => true]); + +// By key +$results = $db->find("users", ["key" => 0]); +$results = $db->find("users", ["key" => [0, 1, 2]]); +``` + +**Returns:** Array of records with `key` field added. + +### update() + +Update matching records. + +```php +// By field +$result = $db->update("users", [ + ["name" => "John"], // Filter + ["set" => ["active" => true]] // Updates +]); +// ["n" => 1] + +// By key +$result = $db->update("users", [ + ["key" => [0, 1, 2]], + ["set" => ["status" => "inactive"]] +]); + +// All records +$result = $db->update("users", [ + [], + ["set" => ["updated_at" => time()]] +]); +``` + +### delete() + +Delete matching records. + +```php +// By field +$result = $db->delete("users", ["name" => "John"]); +// ["n" => 1] + +// By key +$result = $db->delete("users", ["key" => [0, 2]]); + +// All records +$result = $db->delete("users", []); +``` + +--- + +## Query Operations + +### Query Builder + +```php +$query = $db->query("users"); +``` + +### Filter Methods + +| Method | Description | +|--------|-------------| +| `where($filters)` | AND filter with operator support | +| `orWhere($filters)` | OR filter | +| `whereIn($field, $values)` | Value in array | +| `whereNotIn($field, $values)` | Value not in array | +| `whereNot($filters)` | Not equal | +| `like($field, $pattern)` | Pattern match | +| `notLike($field, $pattern)` | Pattern not match | +| `between($field, $min, $max)` | Range (inclusive) | +| `notBetween($field, $min, $max)` | Outside range | +| `search($term, $fields)` | Full-text search | + +### Comparison Operators + +```php +$db->query("users")->where([ + 'age' => ['$gt' => 18], // Greater than + 'age' => ['$gte' => 18], // Greater than or equal + 'age' => ['$lt' => 65], // Less than + 'age' => ['$lte' => 65], // Less than or equal + 'role' => ['$ne' => 'guest'],// Not equal + 'role' => ['$eq' => 'admin'],// Equal (explicit) + 'status' => ['$in' => ['active', 'pending']], // In array + 'status' => ['$nin' => ['banned', 'deleted']], // Not in array + 'email' => ['$exists' => true], // Field exists + 'name' => ['$like' => 'John'], // Pattern match + 'email' => ['$regex' => '@gmail.com$'], // Regex + 'tags' => ['$contains' => 'featured'] // Array/string contains +]); +``` + +### Modifiers + +| Method | Description | +|--------|-------------| +| `select($fields)` | Include only specified fields | +| `except($fields)` | Exclude specified fields | +| `sort($field, $order)` | Sort results | +| `limit($count)` | Limit results | +| `offset($count)` | Skip records | +| `join($db, $localKey, $foreignKey)` | Join databases | +| `groupBy($field)` | Group results | +| `having($aggregate, $op, $value)` | Filter groups | + +### Terminal Methods + +| Method | Returns | +|--------|---------| +| `get()` | All matching records | +| `first()` | First record or null | +| `last()` | Last record or null | +| `count()` | Number of matches | +| `exists()` | Boolean | +| `sum($field)` | Sum of field | +| `avg($field)` | Average of field | +| `min($field)` | Minimum value | +| `max($field)` | Maximum value | +| `distinct($field)` | Unique values | +| `update($set)` | Update matches | +| `delete()` | Delete matches | + +--- + +## Aggregation + +### count() + +```php +$total = $db->count("users", 0); +$active = $db->count("users", ["active" => true]); +``` + +### sum() / avg() + +```php +$totalSalary = $db->sum("users", "salary"); +$avgAge = $db->avg("users", "age"); +$filteredSum = $db->sum("orders", "amount", ["status" => "paid"]); +``` + +### min() / max() + +```php +$minPrice = $db->min("products", "price"); +$maxScore = $db->max("scores", "points"); +``` + +### distinct() + +```php +$cities = $db->distinct("users", "city"); +// ["Istanbul", "Ankara", "Izmir"] +``` + +--- + +## Sharding + +### getShardInfo() + +```php +$info = $db->getShardInfo("users"); +// [ +// "sharded" => true, +// "shards" => 5, +// "totalRecords" => 45000, +// "deletedCount" => 150, +// "shardSize" => 10000, +// "nextKey" => 45150 +// ] +``` + +### compact() + +```php +$result = $db->compact("users"); +// ["success" => true, "freedSlots" => 150, ...] +``` + +### migrate() + +```php +$result = $db->migrate("users"); +// ["success" => true, "status" => "migrated"] +``` + +### Configuration + +```php +$db->isShardingEnabled(); // true/false +$db->getShardSize(); // 10000 +``` + +--- + +## Field Indexing + +### createFieldIndex() + +```php +$result = $db->createFieldIndex("users", "email"); +// ["success" => true] +``` + +### hasFieldIndex() + +```php +$exists = $db->hasFieldIndex("users", "email"); +// true/false +``` + +### dropFieldIndex() + +```php +$result = $db->dropFieldIndex("users", "email"); +``` + +--- + +## Spatial Indexing + +### createSpatialIndex() + +```php +$result = $db->createSpatialIndex("locations", "coords"); +// ["success" => true, "indexed" => 150] + +// Records with invalid/missing GeoJSON are skipped (not indexed) +// Always check 'indexed' count to verify +``` + +### hasSpatialIndex() + +```php +$exists = $db->hasSpatialIndex("locations", "coords"); +// true/false +``` + +### getSpatialIndexes() + +```php +$indexes = $db->getSpatialIndexes("locations"); +// ["coords", "boundary"] +``` + +### dropSpatialIndex() + +```php +$result = $db->dropSpatialIndex("locations", "coords"); +``` + +### rebuildSpatialIndex() + +```php +$result = $db->rebuildSpatialIndex("locations", "coords"); +// Drops and recreates the spatial index from existing data +``` + +### withinDistance() + +```php +$nearby = $db->withinDistance("locations", "coords", 28.97, 41.00, 5000); +// Records within 5000 meters (5km) +``` + +### withinBBox() + +```php +$inArea = $db->withinBBox("locations", "coords", 28.97, 41.00, 29.00, 41.03); +``` + +### nearest() + +```php +$closest = $db->nearest("locations", "coords", 28.97, 41.00, 5); +// 5 nearest records +``` + +### withinPolygon() + +```php +$polygon = ['type' => 'Polygon', 'coordinates' => [...]]; +$inside = $db->withinPolygon("locations", "coords", $polygon); +``` + +### validateGeoJSON() + +```php +$result = $db->validateGeoJSON($geometry); +// ["valid" => true] or ["valid" => false, "error" => "..."] +``` + +--- + +## Utility Methods + +### sort() + +```php +$sorted = $db->sort($results, "name", "asc"); +$sorted = $db->sort($results, "created_at", "desc"); +``` + +### limit() + +```php +$limited = $db->limit($results, 10); +``` + +### first() / last() + +```php +$first = $db->first("users"); +$last = $db->last("users", ["active" => true]); +``` + +### exists() + +```php +$exists = $db->exists("users", ["email" => "test@test.com"]); +// true/false +``` + +### like() + +```php +$results = $db->like("users", "email", "gmail"); // Contains +$results = $db->like("users", "name", "^John"); // Starts with +$results = $db->like("users", "name", "son$"); // Ends with +``` + +### between() + +```php +$results = $db->between("products", "price", 100, 500); +$results = $db->between("products", "price", 100, 500, ["active" => true]); +``` + +### Static Cache + +```php +noneDB::clearStaticCache(); +noneDB::disableStaticCache(); +noneDB::enableStaticCache(); +``` + +### Configuration + +```php +noneDB::configExists(); // Check config file +noneDB::getConfigTemplate(); // Get template path +noneDB::clearConfigCache(); // Clear cached config +noneDB::setDevMode(true); // Enable dev mode +noneDB::isDevMode(); // Check dev mode +``` diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 0000000..3de3158 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,371 @@ +# noneDB Performance Benchmarks + +Comprehensive performance benchmarks and database comparisons. + +**Version:** 3.1.0 +**Test Environment:** PHP 8.2, macOS (Apple Silicon M-series) + +--- + +## Table of Contents + +1. [Test Data Structure](#test-data-structure) +2. [v3.0+ Optimizations](#v30-optimizations) +3. [O(1) Key Lookup Performance](#o1-key-lookup-performance) +4. [Write Operations](#write-operations) +5. [Read Operations](#read-operations) +6. [Query & Aggregation](#query--aggregation) +7. [Method Chaining](#method-chaining) +8. [Spatial Query Performance](#spatial-query-performance) +9. [Storage & Memory](#storage--memory) +10. [SleekDB Comparison](#sleekdb-comparison) + +--- + +## Test Data Structure + +All benchmarks use 7 fields per record: + +```php +[ + "name" => "User123", + "email" => "user123@test.com", + "age" => 25, + "salary" => 8500, + "city" => "Istanbul", + "department" => "IT", + "active" => true +] +``` + +--- + +## v3.0+ Optimizations + +| Optimization | Improvement | +|--------------|-------------| +| **Static Cache Sharing** | 80%+ for multi-instance | +| **Batch File Read** | 40-50% for bulk reads | +| **Batch Update/Delete** | **25-30x faster** for bulk operations | +| **Single-Pass Filtering** | 30% for complex queries | +| **O(1) Sharded Key Lookup** | True O(1) for all database sizes | +| **O(1) Count** | **100-330x faster** (index metadata lookup) | +| **Hash Cache Persistence** | Faster cold startup | +| **atomicReadFast()** | Optimized index reads | + +### v3.1.0 Spatial Optimizations + +| Optimization | Improvement | +|--------------|-------------| +| **Parent Pointer Map** | O(1) parent lookup (was O(n)) | +| **Linear Split Algorithm** | O(n) seed selection (was O(n²)) | +| **Dirty Flag Pattern** | Single disk write per batch (was n writes) | +| **Distance Memoization** | Cached Haversine calculations | +| **Centroid Caching** | Cached geometry centroids | +| **Node Size 32** | Fewer tree levels and splits | +| **Adaptive nearest()** | Exponential radius expansion | + +--- + +## O(1) Key Lookup Performance + +Key lookups are **constant time** regardless of database size after cache warm-up. + +| Records | Cold (first access) | Warm (cached) | Notes | +|---------|---------------------|---------------|-------| +| 100 | 3 ms | **0.03 ms** | Non-sharded | +| 1K | 3 ms | **0.03 ms** | Non-sharded | +| 10K | 49 ms | **0.03 ms** | Sharded (1 shard) | +| 50K | 243 ms | **0.05 ms** | Sharded (5 shards) | +| 100K | 497 ms | **0.05 ms** | Sharded (10 shards) | +| 500K | 2.5 s | **0.16 ms** | Sharded (50 shards) | + +> **Key insight:** Cold time includes loading shard index. After cache warm-up, all lookups are near-instant. + +--- + +## Write Operations + +| Operation | 100 | 1K | 10K | 50K | 100K | 500K | +|-----------|-----|-----|------|------|-------|-------| +| insert() | 7 ms | 25 ms | 289 ms | 1.5 s | 3.1 s | 16.5 s | +| update() | 1 ms | 11 ms | 120 ms | 660 ms | 1.5 s | 11.3 s | +| delete() | 2 ms | 13 ms | 144 ms | 773 ms | 1.7 s | 12.5 s | + +> Update/delete use batch operations for efficient bulk modifications (single index write per shard) + +--- + +## Read Operations + +| Operation | 100 | 1K | 10K | 50K | 100K | 500K | +|-----------|-----|-----|------|------|-------|-------| +| find(all) | 3 ms | 23 ms | 48 ms | 268 ms | 602 ms | 2.7 s | +| find(key) | <1 ms | <1 ms | 49 ms | 243 ms | 497 ms | 2.5 s | +| find(filter) | <1 ms | 4 ms | 50 ms | 252 ms | 515 ms | 2.6 s | + +> **find(key)** first call includes index loading. Subsequent calls: ~0.05ms (see O(1) table above) + +--- + +## Query & Aggregation + +| Operation | 100 | 1K | 10K | 50K | 100K | 500K | +|-----------|-----|-----|------|------|-------|-------| +| count() | **<1 ms** | **<1 ms** | **<1 ms** | **<1 ms** | **<1 ms** | **<1 ms** | +| distinct() | <1 ms | 4 ms | 49 ms | 270 ms | 590 ms | 2.9 s | +| sum() | <1 ms | 4 ms | 49 ms | 261 ms | 588 ms | 3 s | +| like() | <1 ms | 5 ms | 57 ms | 311 ms | 670 ms | 3.4 s | +| between() | <1 ms | 4 ms | 53 ms | 288 ms | 628 ms | 3.2 s | +| sort() | <1 ms | 8 ms | 105 ms | 565 ms | 1.3 s | 7.1 s | +| first() | <1 ms | 4 ms | 50 ms | 285 ms | 589 ms | 2.9 s | +| exists() | <1 ms | 4 ms | 49 ms | 272 ms | 588 ms | 3 s | + +> **count()** uses O(1) index metadata lookup - no record scanning required! + +--- + +## Method Chaining + +| Operation | 100 | 1K | 10K | 50K | 100K | 500K | +|-----------|-----|-----|------|------|-------|-------| +| whereIn() | <1 ms | 4 ms | 53 ms | 302 ms | 657 ms | 3.6 s | +| orWhere() | <1 ms | 4 ms | 55 ms | 316 ms | 673 ms | 3.5 s | +| search() | <1 ms | 5 ms | 61 ms | 350 ms | 762 ms | 4.2 s | +| groupBy() | <1 ms | 4 ms | 52 ms | 307 ms | 657 ms | 3.5 s | +| select() | <1 ms | 5 ms | 57 ms | 400 ms | 854 ms | 4.5 s | +| complex chain | <1 ms | 5 ms | 60 ms | 322 ms | 684 ms | 3.6 s | + +> **Complex chain:** `where() + whereIn() + between() + select() + sort() + limit()` + +--- + +## Comparison Operators (v3.1.0) + +MongoDB-style operators add minimal overhead: + +| Operator | 100 | 500 | 1K | 5K | +|----------|-----|-----|-----|-----| +| `$gt` | 0.7 ms | 3 ms | 6 ms | 33 ms | +| `$gte + $lte` (range) | 0.7 ms | 3.4 ms | 7 ms | 38 ms | +| `$in` (2 values) | 0.7 ms | 3.3 ms | 6.5 ms | 36 ms | +| `$nin` (2 values) | 0.7 ms | 3.5 ms | 7 ms | 37 ms | +| `$ne` | 0.7 ms | 3 ms | 6 ms | 36 ms | +| `$like` | 0.7 ms | 3.4 ms | 7 ms | 39 ms | +| `$exists` | 0.6 ms | 3.2 ms | 6 ms | 37 ms | +| Complex (4 operators) | 0.7 ms | 3.5 ms | 7.5 ms | 43 ms | + +> Operators add <1ms overhead per operation. Linear scaling with record count. + +--- + +## Spatial Query Performance + +Tested with R-tree spatial index (v3.1.0): + +| Operation | 100 | 500 | 1K | 5K | +|-----------|-----|-----|-----|-----| +| createSpatialIndex | 2.4 ms | 34 ms | 81 ms | 423 ms | +| withinDistance (10km) | 3.1 ms | 16 ms | 32 ms | 166 ms | +| withinBBox | 0.7 ms | 4.5 ms | 7 ms | 38 ms | +| nearest(10) | 1.9 ms | 1.3 ms | 2 ms | 2.4 ms | + +> Spatial queries use R-tree indexing for O(log n + k) performance where k = matching records. + +### Spatial + Operator Combination + +| Query Type | 100 | 500 | 1K | 5K | +|------------|-----|-----|-----|-----| +| withinDistance only | 3 ms | 16 ms | 32 ms | 166 ms | +| + where (simple) | 3 ms | 17 ms | 34 ms | 174 ms | +| + where (`$gte`) | 3 ms | 17 ms | 33 ms | 175 ms | +| + where (`$in`) | 3 ms | 16.5 ms | 33 ms | 174 ms | +| + range (`$gte` + `$lte`) | 3 ms | 17 ms | 36 ms | 212 ms | +| + complex (4 operators) | 3 ms | 16.5 ms | 34.5 ms | 179 ms | +| + sort + limit | 3 ms | 17 ms | 35 ms | 183 ms | +| nearest + operators + limit | 8 ms | 4.5 ms | 7 ms | 12 ms | + +> Spatial + operator combinations add minimal overhead. R-tree filtering happens first, then operators applied to candidates. + +--- + +## Storage & Memory + +| Records | File Size | Peak Memory | +|---------|-----------|-------------| +| 100 | 10 KB | 2 MB | +| 1,000 | 98 KB | 4 MB | +| 10,000 | 1 MB | 8 MB | +| 50,000 | 5 MB | 34 MB | +| 100,000 | 10 MB | 134 MB | +| 500,000 | 50 MB | ~600 MB | + +--- + +## SleekDB Comparison + +### Why Choose noneDB? + +noneDB v3.0+ excels in **bulk operations** and **large datasets**: + +| Strength | Performance | +|----------|-------------| +| **Bulk Insert** | **8-10x faster** than SleekDB | +| **Find All** | **8-66x faster** at scale | +| **Filter Queries** | **20-80x faster** at scale | +| **Update Operations** | **15-40x faster** on large datasets | +| **Delete Operations** | **5-23x faster** on large datasets | +| **Count Operations** | **90-330x faster** (O(1) index lookup) | +| **Complex Queries** | **22-70x faster** at scale | +| **Large Datasets** | Handles 500K+ records with auto-sharding | +| **Thread Safety** | Atomic file locking for concurrent access | +| **Static Cache** | Cross-instance cache sharing | +| **Spatial Queries** | R-tree indexing (SleekDB: none) | + +**Best for:** Bulk operations, analytics, batch processing, filter-heavy workloads, count operations, geospatial queries + +### When to Consider SleekDB? + +| Scenario | SleekDB Advantage | +|----------|-------------------| +| **High-frequency key lookups** | <1ms vs ~500ms cold (file-per-record architecture) | +| **Very low memory** | Lower RAM usage | + +> **Note:** SleekDB stores each record as a separate file, making single-record lookups instant but bulk operations slow. + +--- + +### Architectural Differences + +| Feature | SleekDB | noneDB | +|---------|---------|--------| +| **Storage** | One JSON file per record | JSONL + byte-offset index | +| **ID Access** | Direct file read (O(1)) | Index lookup + seek | +| **Bulk Read** | Traverse all files | Single file read | +| **Sharding** | None | Automatic (10K+) | +| **Cache** | Per-query | Static cross-instance | +| **Indexing** | None | Byte-offset (.jidx) | +| **Spatial Index** | None | R-tree (v3.1) | + +--- + +### Detailed Benchmark Results + +#### Bulk Insert + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | 7ms | 24ms | **noneDB 3x** | +| 1K | 26ms | 250ms | **noneDB 10x** | +| 10K | 306ms | 2.89s | **noneDB 9x** | +| 50K | 1.59s | 12.4s | **noneDB 8x** | +| 100K | 3.34s | 30.76s | **noneDB 9x** | + +#### Find All Records + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | 3ms | 28ms | **noneDB 8x** | +| 1K | 7ms | 286ms | **noneDB 42x** | +| 10K | 65ms | 2.71s | **noneDB 42x** | +| 50K | 300ms | 16.83s | **noneDB 56x** | +| 100K | 595ms | 39.03s | **noneDB 66x** | + +#### Find by Key (Single Record - Cold) + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | 3ms | <1ms | SleekDB | +| 1K | 3ms | <1ms | SleekDB | +| 10K | 55ms | <1ms | **SleekDB** | +| 50K | 287ms | <1ms | **SleekDB** | +| 100K | 561ms | <1ms | **SleekDB** | + +> **Note:** SleekDB's file-per-record design gives O(1) key lookup. noneDB must load shard index first (but subsequent lookups are O(1) with cache). + +#### Find with Filter + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | <1ms | 10ms | **noneDB 24x** | +| 1K | 4ms | 94ms | **noneDB 25x** | +| 10K | 49ms | 998ms | **noneDB 20x** | +| 50K | 254ms | 13.18s | **noneDB 52x** | +| 100K | 524ms | 41.64s | **noneDB 79x** | + +#### Count Operations + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | <1ms | <1ms | **noneDB 4x** | +| 1K | <1ms | 1ms | **noneDB 11x** | +| 10K | <1ms | 9ms | **noneDB 90x** | +| 50K | <1ms | 51ms | **noneDB 330x** | +| 100K | <1ms | 96ms | **noneDB 258x** | + +> **v3.0 Optimization:** noneDB uses O(1) index metadata lookup for count() - no record scanning! + +#### Update Operations + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | 1ms | 20ms | **noneDB 15x** | +| 1K | 11ms | 188ms | **noneDB 17x** | +| 10K | 118ms | 2.14s | **noneDB 18x** | +| 50K | 669ms | 20.91s | **noneDB 31x** | +| 100K | 1.53s | 61.27s | **noneDB 40x** | + +#### Delete Operations + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | 2ms | 10ms | **noneDB 5x** | +| 1K | 15ms | 105ms | **noneDB 7x** | +| 10K | 150ms | 1.27s | **noneDB 8x** | +| 50K | 839ms | 14.61s | **noneDB 17x** | +| 100K | 1.75s | 40.01s | **noneDB 23x** | + +#### Complex Query (where + sort + limit) + +| Records | noneDB | SleekDB | Winner | +|---------|--------|---------|--------| +| 100 | <1ms | 12ms | **noneDB 27x** | +| 1K | 4ms | 114ms | **noneDB 30x** | +| 10K | 55ms | 1.2s | **noneDB 22x** | +| 50K | 295ms | 15.33s | **noneDB 52x** | +| 100K | 591ms | 41.3s | **noneDB 70x** | + +--- + +### Summary + +| Use Case | Winner | Advantage | +|----------|--------|-----------| +| **Bulk Insert** | **noneDB** | 3-10x faster | +| **Find All** | **noneDB** | 8-66x faster | +| **Find with Filter** | **noneDB** | 20-79x faster | +| **Update** | **noneDB** | 15-40x faster | +| **Delete** | **noneDB** | 5-23x faster | +| **Complex Query** | **noneDB** | 22-70x faster | +| **Count** | **noneDB** | 4-330x faster (O(1) index lookup) | +| **Spatial Queries** | **noneDB** | R-tree indexing (SleekDB: none) | +| **Find by Key (cold)** | **SleekDB** | O(1) file access | + +> **Choose noneDB** for: Bulk operations, large datasets, filter queries, update/delete workloads, complex queries, count operations, geospatial queries +> +> **Choose SleekDB** for: High-frequency single-record lookups by ID (cold cache scenarios) + +--- + +## Running Benchmarks + +```bash +# Performance benchmark +php tests/performance_benchmark.php + +# SleekDB comparison (requires SleekDB installed) +php tests/sleekdb_comparison.php + +# Spatial benchmark +php tests/spatial_benchmark.php +``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..54c5f40 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,471 @@ +# noneDB Configuration Reference + +Complete configuration options and security best practices. + +**Version:** 3.1.0 + +--- + +## Table of Contents + +1. [Configuration Methods](#configuration-methods) +2. [Configuration Options](#configuration-options) +3. [Security Best Practices](#security-best-practices) +4. [Development Mode](#development-mode) +5. [Sharding Configuration](#sharding-configuration) +6. [Performance Tuning](#performance-tuning) + +--- + +## Configuration Methods + +### 1. Config File (Recommended) + +Create a `.nonedb` file in your project root: + +```json +{ + "secretKey": "YOUR_SECURE_RANDOM_STRING_HERE", + "dbDir": "./db/", + "autoCreateDB": true, + "shardingEnabled": true, + "shardSize": 10000, + "autoMigrate": true, + "autoCompactThreshold": 0.3, + "lockTimeout": 5, + "lockRetryDelay": 10000 +} +``` + +**Setup:** + +```bash +cp .nonedb.example .nonedb +# Edit .nonedb with your settings +``` + +**Usage:** + +```php +// Automatically reads .nonedb from project root +$db = new noneDB(); +``` + +### 2. Programmatic Configuration + +```php +$db = new noneDB([ + 'secretKey' => 'your_secure_key', + 'dbDir' => '/path/to/db/', + 'autoCreateDB' => true, + 'shardingEnabled' => true, + 'shardSize' => 10000 +]); +``` + +### 3. Mixed Configuration + +Config file values can be overridden: + +```php +// Base config from .nonedb, override dbDir +$db = new noneDB([ + 'dbDir' => '/custom/path/' +]); +``` + +--- + +## Configuration Options + +### Core Settings + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `secretKey` | string | *required* | Secret key for hashing database names. **Must be unique and secure.** | +| `dbDir` | string | `./db/` | Directory for database files. Created automatically if doesn't exist. | +| `autoCreateDB` | bool | `true` | Auto-create databases on first use. Set `false` in production. | + +### Sharding Settings + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `shardingEnabled` | bool | `true` | Enable auto-sharding for large datasets. | +| `shardSize` | int | `10000` | Records per shard. Recommended: 10,000-100,000. | +| `autoMigrate` | bool | `true` | Auto-migrate to sharded format when threshold reached. | + +### Compaction Settings + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `autoCompactThreshold` | float | `0.3` | Trigger compaction when deleted > 30% of records. Range: 0.1-0.9. | + +### Lock Settings + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `lockTimeout` | int | `5` | File lock timeout in seconds. | +| `lockRetryDelay` | int | `10000` | Lock retry delay in microseconds (10ms default). | + +### Field Indexing (v3.0+) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `fieldIndexEnabled` | bool | `true` | Enable field indexing for faster filtered queries. | + +--- + +## Security Best Practices + +### 1. Secret Key Security + +```php +// BAD: Hardcoded secret +$db = new noneDB(['secretKey' => 'mysecret']); + +// GOOD: Environment variable +$db = new noneDB(['secretKey' => getenv('NONEDB_SECRET')]); + +// GOOD: Config file (not in git) +// Use .nonedb file and add to .gitignore +``` + +**Generate secure key:** + +```php +// PHP 7+ +$secretKey = bin2hex(random_bytes(32)); + +// Or use command line +// openssl rand -hex 32 +``` + +### 2. Protect Config File + +Add to `.gitignore`: + +```gitignore +.nonedb +db/ +*.nonedb +*.nonedb.jidx +``` + +### 3. Protect Database Directory + +Create `db/.htaccess`: + +```apache +# Deny all access +Deny from all +``` + +Or for Apache 2.4+: + +```apache +Require all denied +``` + +**Nginx:** + +```nginx +location ~ \.nonedb { + deny all; +} +``` + +### 4. Disable Auto-Create in Production + +```json +{ + "autoCreateDB": false +} +``` + +This prevents accidental database creation from typos or injection. + +### 5. Database Directory Location + +```php +// BAD: Inside web root +$db = new noneDB(['dbDir' => './public/db/']); + +// GOOD: Outside web root +$db = new noneDB(['dbDir' => '/var/data/nonedb/']); + +// GOOD: Protected directory +$db = new noneDB(['dbDir' => './storage/db/']); // with .htaccess +``` + +--- + +## Development Mode + +For development without config file: + +### Enable Dev Mode + +```php +// Option 1: Static method (recommended) +noneDB::setDevMode(true); +$db = new noneDB(); + +// Option 2: Environment variable +putenv('NONEDB_DEV_MODE=1'); +$db = new noneDB(); + +// Option 3: PHP constant +define('NONEDB_DEV_MODE', true); +$db = new noneDB(); +``` + +### Dev Mode Defaults + +When dev mode is enabled without config: + +| Option | Dev Mode Default | +|--------|------------------| +| secretKey | `"development_secret_key_change_in_production"` | +| dbDir | `./db/` | +| autoCreateDB | `true` | +| shardingEnabled | `true` | + +### Check Dev Mode Status + +```php +if (noneDB::isDevMode()) { + echo "Running in development mode"; +} +``` + +### Warning + +Dev mode should **never** be used in production. It uses a known default secret key. + +--- + +## Sharding Configuration + +### When to Enable Sharding + +| Dataset Size | Recommendation | +|--------------|----------------| +| < 10K records | Disable sharding | +| 10K - 100K | Enable with default shard size (10K) | +| 100K - 500K | Enable with larger shard size (50K-100K) | +| > 500K | Enable with optimized shard size | + +### Shard Size Tuning + +```php +// Small shards = more files, faster individual shard access +$db = new noneDB(['shardSize' => 5000]); + +// Large shards = fewer files, better bulk operations +$db = new noneDB(['shardSize' => 50000]); +``` + +**Trade-offs:** + +| Shard Size | Pros | Cons | +|------------|------|------| +| Small (5K) | Fast shard access | More files to manage | +| Medium (10K) | Balanced | Default recommendation | +| Large (50K+) | Fewer files | Slower shard operations | + +### Manual Migration Control + +```php +// Disable auto-migration +$db = new noneDB([ + 'autoMigrate' => false +]); + +// Manually migrate when ready +$result = $db->migrate("users"); +``` + +### Check Sharding Status + +```php +$info = $db->getShardInfo("users"); +// { +// "sharded": true, +// "shards": 5, +// "totalRecords": 45000, +// "shardSize": 10000 +// } +``` + +--- + +## Performance Tuning + +### Compaction Threshold + +```php +// Aggressive compaction (compact when 10% deleted) +$db = new noneDB(['autoCompactThreshold' => 0.1]); + +// Conservative compaction (compact when 50% deleted) +$db = new noneDB(['autoCompactThreshold' => 0.5]); +``` + +**Recommendations:** + +| Use Case | Threshold | +|----------|-----------| +| High delete rate | 0.2 (20%) | +| Normal operations | 0.3 (30%) - default | +| Disk space priority | 0.5 (50%) | +| Minimal disk writes | 0.7 (70%) | + +### Lock Settings + +For high-concurrency environments: + +```php +// Increase timeout for slower systems +$db = new noneDB([ + 'lockTimeout' => 10, // 10 seconds + 'lockRetryDelay' => 5000 // 5ms between retries +]); +``` + +### Static Cache + +```php +// Disable for memory-constrained environments +noneDB::disableStaticCache(); + +// Re-enable +noneDB::enableStaticCache(); + +// Clear cache (useful between test runs) +noneDB::clearStaticCache(); +``` + +### Field Index Configuration + +```php +// Create indexes for frequently filtered fields +$db->createFieldIndex("users", "email"); +$db->createFieldIndex("users", "status"); + +// Check if index exists +$db->hasFieldIndex("users", "email"); + +// Drop unused index +$db->dropFieldIndex("users", "old_field"); +``` + +--- + +## Configuration Helpers + +### Check Config + +```php +// Check if config file exists +if (noneDB::configExists()) { + echo "Config file found"; +} + +// Get config template path +$template = noneDB::getConfigTemplate(); +// Returns: "/path/to/.nonedb.example" + +// Clear cached config +noneDB::clearConfigCache(); +``` + +### Runtime Configuration Check + +```php +$db = new noneDB(); + +// Check current settings +$db->isShardingEnabled(); // true/false +$db->getShardSize(); // 10000 +``` + +--- + +## Environment-Specific Configuration + +### Development + +```json +{ + "secretKey": "dev_secret_for_testing", + "dbDir": "./db/", + "autoCreateDB": true, + "shardingEnabled": false, + "autoCompactThreshold": 0.5 +} +``` + +### Staging + +```json +{ + "secretKey": "staging_unique_secret", + "dbDir": "/var/data/staging/", + "autoCreateDB": true, + "shardingEnabled": true, + "shardSize": 10000 +} +``` + +### Production + +```json +{ + "secretKey": "GENERATE_UNIQUE_64_CHAR_STRING", + "dbDir": "/var/data/production/", + "autoCreateDB": false, + "shardingEnabled": true, + "shardSize": 50000, + "autoCompactThreshold": 0.3, + "lockTimeout": 10 +} +``` + +--- + +## Troubleshooting + +### "Config file not found" + +```php +// Check if file exists +var_dump(noneDB::configExists()); + +// Use dev mode for quick testing +noneDB::setDevMode(true); +``` + +### "Permission denied" + +```bash +# Fix directory permissions +chmod 755 db/ +chmod 644 .nonedb +``` + +### "Lock timeout" + +```php +// Increase timeout +$db = new noneDB(['lockTimeout' => 30]); +``` + +### "Database not accessible after upgrade" + +The most common cause is changed secretKey. Ensure you're using the same secretKey as before. + +```php +// Database files are named: {hash}-{dbname}.nonedb +// The hash is generated from secretKey + dbname using PBKDF2 +// If you changed your secretKey, old database files won't be found +``` diff --git a/docs/QUERY.md b/docs/QUERY.md new file mode 100644 index 0000000..187a1e5 --- /dev/null +++ b/docs/QUERY.md @@ -0,0 +1,966 @@ +# noneDB Query Reference + +Comprehensive documentation for noneDB's Query Builder API. + +**Version:** 3.1.0 + +--- + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Query Builder Basics](#query-builder-basics) +3. [Comparison Operators](#comparison-operators) +4. [Filter Methods](#filter-methods) +5. [Sorting & Pagination](#sorting--pagination) +6. [Aggregation](#aggregation) +7. [Joins](#joins) +8. [Grouping & Having](#grouping--having) +9. [Spatial Queries](#spatial-queries) +10. [Terminal Methods](#terminal-methods) +11. [Real-World Examples](#real-world-examples) +12. [Performance Tips](#performance-tips) + +--- + +## Quick Start + +```php +$db = new noneDB(); + +// Simple query +$users = $db->query("users") + ->where(['active' => true]) + ->sort('created_at', 'desc') + ->limit(10) + ->get(); + +// Advanced query with operators +$products = $db->query("products") + ->where([ + 'price' => ['$gte' => 100, '$lte' => 500], + 'category' => ['$in' => ['electronics', 'gadgets']], + 'stock' => ['$gt' => 0] + ]) + ->sort('rating', 'desc') + ->get(); +``` + +--- + +## Query Builder Basics + +### Creating a Query + +```php +// Get query builder instance +$query = $db->query("database_name"); + +// Chain methods and execute +$results = $query + ->where(['field' => 'value']) + ->get(); +``` + +### Method Chaining + +All filter and modifier methods return `$this`, allowing fluent chaining: + +```php +$results = $db->query("users") + ->where(['status' => 'active']) + ->whereIn('role', ['admin', 'moderator']) + ->sort('name', 'asc') + ->limit(20) + ->offset(0) + ->get(); +``` + +--- + +## Comparison Operators + +noneDB supports MongoDB-style comparison operators in `where()` clauses. + +### Operator Reference + +| Operator | Description | Example | +|----------|-------------|---------| +| `$gt` | Greater than | `['age' => ['$gt' => 18]]` | +| `$gte` | Greater than or equal | `['price' => ['$gte' => 100]]` | +| `$lt` | Less than | `['stock' => ['$lt' => 10]]` | +| `$lte` | Less than or equal | `['rating' => ['$lte' => 5]]` | +| `$eq` | Equal (explicit) | `['status' => ['$eq' => 'active']]` | +| `$ne` | Not equal | `['role' => ['$ne' => 'guest']]` | +| `$in` | Value in array | `['category' => ['$in' => ['a', 'b']]]` | +| `$nin` | Value not in array | `['tag' => ['$nin' => ['spam']]]` | +| `$exists` | Field exists/not exists | `['email' => ['$exists' => true]]` | +| `$like` | Pattern matching | `['name' => ['$like' => 'John']]` | +| `$regex` | Regular expression | `['email' => ['$regex' => '@gmail.com$']]` | +| `$contains` | Array/string contains | `['tags' => ['$contains' => 'featured']]` | + +### Comparison Examples + +#### Greater Than / Less Than + +```php +// Find adults +$adults = $db->query("users") + ->where(['age' => ['$gte' => 18]]) + ->get(); + +// Find products under $100 +$affordable = $db->query("products") + ->where(['price' => ['$lt' => 100]]) + ->get(); + +// Range query (between 18 and 65) +$workingAge = $db->query("users") + ->where(['age' => ['$gte' => 18, '$lte' => 65]]) + ->get(); +``` + +#### Equality / Inequality + +```php +// Not equal +$nonAdmins = $db->query("users") + ->where(['role' => ['$ne' => 'admin']]) + ->get(); + +// Explicit equality (same as simple value) +$active = $db->query("users") + ->where(['status' => ['$eq' => 'active']]) + ->get(); +``` + +#### In / Not In + +```php +// Find users in specific roles +$staff = $db->query("users") + ->where(['role' => ['$in' => ['admin', 'moderator', 'editor']]]) + ->get(); + +// Exclude certain categories +$products = $db->query("products") + ->where(['category' => ['$nin' => ['discontinued', 'draft']]]) + ->get(); +``` + +#### Exists + +```php +// Find records with email field +$withEmail = $db->query("users") + ->where(['email' => ['$exists' => true]]) + ->get(); + +// Find records without phone field +$noPhone = $db->query("users") + ->where(['phone' => ['$exists' => false]]) + ->get(); +``` + +#### Pattern Matching + +```php +// Contains (case-insensitive) +$johns = $db->query("users") + ->where(['name' => ['$like' => 'john']]) + ->get(); + +// Starts with (use ^) +$mNames = $db->query("users") + ->where(['name' => ['$like' => '^M']]) + ->get(); + +// Ends with (use $) +$gmails = $db->query("users") + ->where(['email' => ['$like' => 'gmail.com$']]) + ->get(); + +// Regular expression +$validEmails = $db->query("users") + ->where(['email' => ['$regex' => '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$']]) + ->get(); +``` + +#### Contains (Arrays/Strings) + +```php +// Check if array field contains value +$featuredProducts = $db->query("products") + ->where(['tags' => ['$contains' => 'featured']]) + ->get(); + +// Check if string field contains substring +$techNews = $db->query("articles") + ->where(['title' => ['$contains' => 'technology']]) + ->get(); +``` + +### Combining Multiple Operators + +```php +// Multiple operators on same field +$midRange = $db->query("products") + ->where([ + 'price' => ['$gte' => 50, '$lte' => 200], + 'rating' => ['$gte' => 4.0] + ]) + ->get(); + +// Mix operators with simple equality +$activeAdmins = $db->query("users") + ->where([ + 'role' => 'admin', // Simple equality + 'status' => 'active', // Simple equality + 'login_count' => ['$gt' => 100], // Operator + 'last_login' => ['$exists' => true] // Operator + ]) + ->get(); +``` + +--- + +## Filter Methods + +### where() + +Primary filter method. Applies AND logic across all conditions. + +```php +// Simple equality +$db->query("users")->where(['name' => 'John', 'active' => true]); + +// With operators +$db->query("users")->where([ + 'age' => ['$gte' => 18], + 'country' => ['$in' => ['US', 'UK', 'CA']] +]); +``` + +### orWhere() + +Adds OR conditions to the query. + +```php +// Users who are admins OR have high reputation +$db->query("users") + ->where(['role' => 'admin']) + ->orWhere(['reputation' => ['$gte' => 1000]]) + ->get(); + +// Multiple OR conditions +$db->query("products") + ->where(['category' => 'electronics']) + ->orWhere(['featured' => true]) + ->orWhere(['discount' => ['$gt' => 50]]) + ->get(); +``` + +### whereIn() / whereNotIn() + +Filter by array membership. + +```php +// Users in specific departments +$db->query("users") + ->whereIn('department', ['engineering', 'design', 'product']) + ->get(); + +// Products NOT in these categories +$db->query("products") + ->whereNotIn('category', ['archived', 'deleted']) + ->get(); +``` + +### whereNot() + +Exclude records matching conditions. + +```php +// All users except guests +$db->query("users") + ->whereNot(['role' => 'guest']) + ->get(); +``` + +### like() / notLike() + +Pattern matching on string fields. + +```php +// Names starting with 'A' +$db->query("users")->like('name', '^A')->get(); + +// Names ending with 'son' +$db->query("users")->like('name', 'son$')->get(); + +// Names containing 'john' (case-insensitive) +$db->query("users")->like('name', 'john')->get(); + +// Exclude spam titles +$db->query("posts")->notLike('title', 'SPAM')->get(); +``` + +### between() / notBetween() + +Range filtering (inclusive). + +```php +// Products priced $50-$200 +$db->query("products") + ->between('price', 50, 200) + ->get(); + +// Ages outside 18-65 range +$db->query("users") + ->notBetween('age', 18, 65) + ->get(); +``` + +### search() + +Full-text search across fields. + +```php +// Search in all fields +$db->query("articles") + ->search('php tutorial') + ->get(); + +// Search in specific fields +$db->query("articles") + ->search('php tutorial', ['title', 'content', 'tags']) + ->get(); +``` + +--- + +## Sorting & Pagination + +### sort() + +Sort results by field. + +```php +// Ascending (default) +$db->query("users")->sort('name')->get(); +$db->query("users")->sort('name', 'asc')->get(); + +// Descending +$db->query("products")->sort('price', 'desc')->get(); + +// Sort by distance (spatial queries) +$db->query("locations") + ->withinDistance('coords', $lon, $lat, 10) + ->withDistance('coords', $lon, $lat) + ->sort('_distance', 'asc') + ->get(); +``` + +### limit() / offset() + +Paginate results. + +```php +// First 10 records +$db->query("products")->limit(10)->get(); + +// Page 2 (records 11-20) +$db->query("products")->limit(10)->offset(10)->get(); + +// Helper function for pagination +function paginate($db, $dbname, $page, $perPage = 20) { + return $db->query($dbname) + ->limit($perPage) + ->offset(($page - 1) * $perPage) + ->get(); +} +``` + +--- + +## Aggregation + +### count() + +Count matching records. + +```php +$totalUsers = $db->query("users")->count(); + +$activeUsers = $db->query("users") + ->where(['active' => true]) + ->count(); +``` + +### sum() / avg() / min() / max() + +Numeric aggregations. + +```php +// Total revenue +$revenue = $db->query("orders") + ->where(['status' => 'completed']) + ->sum('total'); + +// Average rating +$avgRating = $db->query("products") + ->where(['category' => 'electronics']) + ->avg('rating'); + +// Price range +$minPrice = $db->query("products")->min('price'); +$maxPrice = $db->query("products")->max('price'); +``` + +--- + +## Joins + +### join() + +Combine data from multiple databases. + +```php +// Join orders with users +$ordersWithUsers = $db->query("orders") + ->join("users", "user_id", "key") // orders.user_id = users.key + ->get(); + +// Result includes user data in _joined field +foreach ($ordersWithUsers as $order) { + echo $order['_joined']['name']; // User's name +} +``` + +### Multiple Joins + +```php +$data = $db->query("order_items") + ->join("orders", "order_id", "key") + ->join("products", "product_id", "key") + ->get(); +``` + +--- + +## Grouping & Having + +### groupBy() + +Group results by field value. + +```php +// Group orders by status +$ordersByStatus = $db->query("orders") + ->groupBy('status') + ->get(); + +// Returns: [ +// 'pending' => [...orders], +// 'completed' => [...orders], +// 'cancelled' => [...orders] +// ] +``` + +### having() + +Filter groups by aggregate conditions. + +```php +// Customers with more than 5 orders +$frequentCustomers = $db->query("orders") + ->groupBy('customer_id') + ->having('count', '>', 5) + ->get(); + +// Categories with average rating >= 4 +$goodCategories = $db->query("products") + ->groupBy('category') + ->having('avg', '>=', 4, 'rating') + ->get(); +``` + +--- + +## Spatial Queries + +noneDB supports geospatial queries with R-tree indexing. + +### Creating Spatial Index + +```php +// Create index on location field (required before spatial queries) +$db->createSpatialIndex("restaurants", "location"); + +// Check if index exists +$db->hasSpatialIndex("restaurants", "location"); // true/false + +// List all spatial indexes +$db->getSpatialIndexes("restaurants"); // ['location'] + +// Drop spatial index +$db->dropSpatialIndex("restaurants", "location"); +``` + +### GeoJSON Data Format + +```php +// Point +$point = [ + 'type' => 'Point', + 'coordinates' => [28.9784, 41.0082] // [longitude, latitude] +]; + +// LineString +$line = [ + 'type' => 'LineString', + 'coordinates' => [ + [28.97, 41.00], + [28.98, 41.01], + [28.99, 41.02] + ] +]; + +// Polygon +$polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], + [29.00, 41.00], + [29.00, 41.03], + [28.97, 41.03], + [28.97, 41.00] // First point repeated to close + ]] +]; +``` + +### withinDistance() + +Find records within radius of a point. + +```php +// Direct method +$nearby = $db->withinDistance("restaurants", "location", + 28.9784, // longitude + 41.0082, // latitude + 5 // radius in km +); + +// Query builder +$nearby = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5) + ->where(['open_now' => true]) + ->get(); +``` + +### withinBBox() + +Find records within bounding box. + +```php +// Direct method +$inArea = $db->withinBBox("restaurants", "location", + 28.97, 41.00, // minLon, minLat + 29.00, 41.03 // maxLon, maxLat +); + +// Query builder +$inArea = $db->query("restaurants") + ->withinBBox('location', 28.97, 41.00, 29.00, 41.03) + ->where(['category' => 'cafe']) + ->get(); +``` + +### nearest() + +Find K nearest records. + +```php +// Direct method +$closest = $db->nearest("restaurants", "location", + 28.9784, 41.0082, // reference point + 5 // number of results +); + +// Query builder with distance +$closest = $db->query("restaurants") + ->nearest('location', 28.9784, 41.0082, 10) + ->where(['rating' => ['$gte' => 4.0]]) + ->withDistance('location', 28.9784, 41.0082) + ->sort('_distance', 'asc') + ->limit(5) + ->get(); +``` + +### withinPolygon() + +Find records within polygon boundary. + +```php +$polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], + [29.00, 41.00], + [29.00, 41.03], + [28.97, 41.03], + [28.97, 41.00] + ]] +]; + +// Direct method +$inPolygon = $db->withinPolygon("restaurants", "location", $polygon); + +// Query builder +$inPolygon = $db->query("restaurants") + ->withinPolygon('location', $polygon) + ->where(['price_range' => ['$lte' => 3]]) + ->get(); +``` + +### withDistance() + +Add calculated distance to results. + +```php +$results = $db->query("restaurants") + ->withinDistance('location', $userLon, $userLat, 10) + ->withDistance('location', $userLon, $userLat) + ->sort('_distance', 'asc') + ->get(); + +foreach ($results as $r) { + echo "{$r['name']}: {$r['_distance']} km away\n"; +} +``` + +### Combining Spatial + Operators + +```php +// Food delivery app: find nearby open restaurants with delivery +$restaurants = $db->query("restaurants") + ->withinDistance('location', $userLon, $userLat, 5) + ->where([ + 'open_now' => true, + 'delivery' => true, + 'rating' => ['$gte' => 4.0], + 'price_range' => ['$lte' => 3], + 'cuisine' => ['$in' => ['turkish', 'italian', 'chinese']] + ]) + ->withDistance('location', $userLon, $userLat) + ->sort('rating', 'desc') + ->limit(20) + ->get(); +``` + +--- + +## Terminal Methods + +Terminal methods execute the query and return results. + +### get() + +Returns all matching records as array. + +```php +$results = $db->query("users")->where(['active' => true])->get(); +``` + +### first() + +Returns first matching record or null. + +```php +$user = $db->query("users")->where(['email' => 'john@example.com'])->first(); +``` + +### count() + +Returns number of matching records. + +```php +$count = $db->query("users")->where(['role' => 'admin'])->count(); +``` + +### exists() + +Returns boolean indicating if any records match. + +```php +$hasAdmins = $db->query("users")->where(['role' => 'admin'])->exists(); +``` + +### update() + +Updates matching records and returns result. + +```php +$result = $db->query("users") + ->where(['status' => 'inactive']) + ->update(['status' => 'archived']); +// Returns: ['n' => 5, 'keys' => [1, 3, 7, 12, 15]] +``` + +### delete() + +Deletes matching records and returns result. + +```php +$result = $db->query("logs") + ->where(['created_at' => ['$lt' => strtotime('-30 days')]]) + ->delete(); +// Returns: ['n' => 100, 'keys' => [...]] +``` + +--- + +## Real-World Examples + +### E-commerce Product Search + +```php +$products = $db->query("products") + ->where([ + 'category' => ['$in' => ['electronics', 'computers']], + 'price' => ['$gte' => 100, '$lte' => 1000], + 'stock' => ['$gt' => 0], + 'rating' => ['$gte' => 4.0] + ]) + ->whereNot(['status' => 'discontinued']) + ->search($searchTerm, ['name', 'description']) + ->sort('rating', 'desc') + ->limit(20) + ->offset($page * 20) + ->get(); +``` + +### User Authentication + +```php +$user = $db->query("users") + ->where([ + 'email' => $email, + 'password_hash' => $passwordHash, + 'status' => 'active', + 'email_verified' => true + ]) + ->first(); +``` + +### Analytics Dashboard + +```php +// Orders by status +$orderStats = $db->query("orders") + ->where(['created_at' => ['$gte' => $startDate, '$lte' => $endDate]]) + ->groupBy('status') + ->get(); + +// Revenue by category +$categoryRevenue = []; +foreach ($db->query("orders")->where(['status' => 'completed'])->get() as $order) { + $cat = $order['category']; + $categoryRevenue[$cat] = ($categoryRevenue[$cat] ?? 0) + $order['total']; +} +``` + +### Location-Based Service + +```php +// Find nearby services with filters +$services = $db->query("services") + ->withinDistance('location', $userLon, $userLat, 10) + ->where([ + 'available' => true, + 'rating' => ['$gte' => 4.0], + 'price_per_hour' => ['$lte' => $maxBudget], + 'category' => ['$in' => $selectedCategories] + ]) + ->withDistance('location', $userLon, $userLat) + ->sort('_distance', 'asc') + ->limit(10) + ->get(); +``` + +### Content Management + +```php +// Published articles with tags +$articles = $db->query("articles") + ->where([ + 'status' => 'published', + 'published_at' => ['$lte' => time()], + 'tags' => ['$contains' => 'featured'] + ]) + ->sort('published_at', 'desc') + ->limit(10) + ->get(); + +// Search with category filter +$results = $db->query("articles") + ->where(['category' => ['$in' => ['tech', 'science']]]) + ->search($query, ['title', 'content', 'excerpt']) + ->sort('published_at', 'desc') + ->get(); +``` + +--- + +## Performance Tips + +### 1. Use Indexes + +```php +// Create field index for frequently queried fields +$db->createFieldIndex("users", "email"); +$db->createFieldIndex("products", "category"); + +// Create spatial index for location queries +$db->createSpatialIndex("locations", "coords"); +``` + +### 2. Limit Results + +```php +// Always limit when you don't need all results +$db->query("logs")->limit(100)->get(); + +// Use first() instead of get()[0] +$db->query("users")->where(['id' => $id])->first(); +``` + +### 3. Select Only Needed Fields + +```php +// Only fetch name and email +$db->query("users")->select(['name', 'email'])->get(); + +// Exclude large fields +$db->query("articles")->except(['content', 'raw_html'])->get(); +``` + +### 4. Filter Early + +```php +// Good: Filter with where() first +$db->query("orders") + ->where(['status' => 'completed']) // Filter first + ->between('total', 100, 500) // Then range + ->sort('created_at', 'desc') + ->get(); +``` + +### 5. Use Spatial Indexes for Location Queries + +```php +// Always create spatial index before queries +$db->createSpatialIndex("restaurants", "location"); + +// Then queries are O(log n) instead of O(n) +$db->withinDistance("restaurants", "location", $lon, $lat, 5); +``` + +### 6. Batch Operations + +```php +// Batch insert instead of individual inserts +$db->insert("logs", $arrayOf1000Records); // Single operation + +// Batch update +$db->query("orders") + ->where(['status' => 'pending', 'created_at' => ['$lt' => $oldDate]]) + ->update(['status' => 'expired']); +``` + +--- + +## Operator Quick Reference + +### Comparison Operators + +```php +['$gt' => value] // Greater than +['$gte' => value] // Greater than or equal +['$lt' => value] // Less than +['$lte' => value] // Less than or equal +['$eq' => value] // Equal (explicit) +['$ne' => value] // Not equal +['$in' => [a,b,c]] // In array +['$nin' => [a,b,c]] // Not in array +['$exists' => bool] // Field exists +['$like' => pattern] // Pattern match (^start, end$) +['$regex' => pattern] // Regex match +['$contains' => val] // Array/string contains +``` + +### Spatial Methods + +```php +->withinDistance($field, $lon, $lat, $km) +->withinBBox($field, $minLon, $minLat, $maxLon, $maxLat) +->nearest($field, $lon, $lat, $k) +->withinPolygon($field, $polygon) +->withDistance($field, $lon, $lat) +``` + +### Filter Methods + +```php +->where([...]) +->orWhere([...]) +->whereIn($field, [...]) +->whereNotIn($field, [...]) +->whereNot([...]) +->like($field, $pattern) +->notLike($field, $pattern) +->between($field, $min, $max) +->notBetween($field, $min, $max) +->search($term, $fields) +``` + +### Modifier Methods + +```php +->sort($field, 'asc'|'desc') +->limit($count) +->offset($count) +->select([...]) +->except([...]) +->join($db, $localKey, $foreignKey) +->groupBy($field) +->having($aggregate, $operator, $value) +``` + +### Terminal Methods + +```php +->get() // Array of records +->first() // Single record or null +->count() // Integer count +->exists() // Boolean +->sum($field) // Numeric sum +->avg($field) // Numeric average +->min($field) // Minimum value +->max($field) // Maximum value +->update([...])// Update result +->delete() // Delete result +``` + +--- + +## Version History + +| Version | Changes | +|---------|---------| +| 3.1.0 | Added comparison operators ($gt, $gte, $lt, $lte, $ne, $eq, $in, $nin, $exists, $like, $regex, $contains) | +| 3.1.0 | Added spatial indexing with R-tree (withinDistance, withinBBox, nearest, withinPolygon) | +| 3.1.0 | Added withDistance() for distance calculations | +| 3.0.0 | JSONL storage with O(1) key lookup | +| 3.0.0 | Single-pass filtering optimization | +| 3.0.0 | Static cache sharing across instances | diff --git a/docs/SPATIAL.md b/docs/SPATIAL.md new file mode 100644 index 0000000..d877d98 --- /dev/null +++ b/docs/SPATIAL.md @@ -0,0 +1,724 @@ +# noneDB Spatial Index Reference + +Comprehensive documentation for noneDB's geospatial capabilities using R-tree indexing. + +**Version:** 3.1.0 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Creating Spatial Indexes](#creating-spatial-indexes) +3. [GeoJSON Data Format](#geojson-data-format) +4. [Spatial Query Methods](#spatial-query-methods) +5. [Query Builder Integration](#query-builder-integration) +6. [Combining Spatial with Filters](#combining-spatial-with-filters) +7. [Performance Optimization](#performance-optimization) +8. [Real-World Examples](#real-world-examples) + +--- + +## Overview + +noneDB v3.1 introduces spatial indexing with R-tree data structure for efficient geospatial queries. Features include: + +- **R-tree indexing** with O(log n) query performance +- **GeoJSON support** for Point, LineString, Polygon, and Multi* types +- **Distance calculations** using Haversine formula +- **Query builder integration** for combining spatial + attribute filters +- **Automatic CRUD synchronization** - index updates on insert/update/delete + +--- + +## Creating Spatial Indexes + +### createSpatialIndex() + +Create an R-tree spatial index on a geometry field. + +```php +$db = new noneDB(); + +// Create spatial index on 'location' field +$result = $db->createSpatialIndex("restaurants", "location"); +// Returns: ["success" => true, "indexed" => 150] + +// If index already exists: +// Returns: ["success" => false, "indexed" => 0, "error" => "Spatial index already exists..."] +``` + +**Important:** Records with invalid or missing GeoJSON are silently skipped during indexing: + +```php +// These records will be SKIPPED (not indexed): +$db->insert("places", ["name" => "No location"]); // Missing field +$db->insert("places", ["name" => "Bad", "location" => "not array"]); // Not an array +$db->insert("places", ["name" => "Bad", "location" => ["x" => 1]]); // Missing 'type' +$db->insert("places", ["name" => "Bad", "location" => [ + "type" => "Point", + "coordinates" => [200, 100] // Invalid: lon > 180 +]]); + +// Check how many records were actually indexed: +$result = $db->createSpatialIndex("places", "location"); +echo "Indexed: {$result['indexed']} records\n"; // May be 0! + +// To validate before insert: +$validation = $db->validateGeoJSON($geometry); +if (!$validation['valid']) { + echo "Error: " . $validation['error']; +} +``` + +### hasSpatialIndex() + +Check if a spatial index exists. + +```php +$exists = $db->hasSpatialIndex("restaurants", "location"); +// Returns: true or false +``` + +### getSpatialIndexes() + +List all spatial indexes for a database. + +```php +$indexes = $db->getSpatialIndexes("restaurants"); +// Returns: ["location", "delivery_area"] +``` + +### dropSpatialIndex() + +Remove a spatial index. + +```php +$result = $db->dropSpatialIndex("restaurants", "location"); +// Returns: ["success" => true] +``` + +--- + +## GeoJSON Data Format + +noneDB supports standard GeoJSON geometry types. Coordinates are `[longitude, latitude]`. + +### Point + +A single location. + +```php +$location = [ + 'type' => 'Point', + 'coordinates' => [28.9784, 41.0082] // [lon, lat] +]; + +$db->insert("restaurants", [ + 'name' => 'Ottoman Kitchen', + 'location' => $location +]); +``` + +### LineString + +A path or route. + +```php +$route = [ + 'type' => 'LineString', + 'coordinates' => [ + [28.97, 41.00], + [28.98, 41.01], + [28.99, 41.02] + ] +]; + +$db->insert("routes", [ + 'name' => 'Scenic Drive', + 'path' => $route +]); +``` + +### Polygon + +An area with boundary. First and last points must be identical (closed ring). + +```php +$area = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], // First point + [29.00, 41.00], + [29.00, 41.03], + [28.97, 41.03], + [28.97, 41.00] // Last point = First point (closed) + ]] +]; + +$db->insert("zones", [ + 'name' => 'Delivery Zone A', + 'boundary' => $area +]); +``` + +### Polygon with Hole + +Polygon with inner exclusion zone. + +```php +$parkWithLake = [ + 'type' => 'Polygon', + 'coordinates' => [ + // Outer ring (park boundary) + [[28.97, 41.00], [29.03, 41.00], [29.03, 41.06], [28.97, 41.06], [28.97, 41.00]], + // Inner ring (lake - hole) + [[28.99, 41.02], [29.01, 41.02], [29.01, 41.04], [28.99, 41.04], [28.99, 41.02]] + ] +]; +``` + +### MultiPoint + +Multiple points in one geometry. + +```php +$locations = [ + 'type' => 'MultiPoint', + 'coordinates' => [ + [28.98, 41.00], + [28.99, 41.01], + [29.00, 41.02] + ] +]; +``` + +### MultiLineString + +Multiple paths in one geometry. + +```php +$routes = [ + 'type' => 'MultiLineString', + 'coordinates' => [ + [[28.97, 41.00], [28.98, 41.01]], + [[29.00, 41.02], [29.01, 41.03]] + ] +]; +``` + +### MultiPolygon + +Multiple polygons in one geometry. + +```php +$zones = [ + 'type' => 'MultiPolygon', + 'coordinates' => [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]] + ] +]; +``` + +--- + +## Spatial Query Methods + +### withinDistance() + +Find records within a radius of a point. **All distances are in meters.** + +```php +// Direct method +$nearby = $db->withinDistance( + "restaurants", // database + "location", // spatial field + 28.9784, // center longitude + 41.0082, // center latitude + 5000 // radius in meters (5km) +); + +// Returns array of records within 5000 meters +foreach ($nearby as $restaurant) { + echo $restaurant['name'] . "\n"; +} +``` + +**Options:** + +```php +$nearby = $db->withinDistance("restaurants", "location", 28.9784, 41.0082, 5000, [ + 'includeDistance' => true // Add _distance field to results +]); + +// Each result now has _distance in meters +foreach ($nearby as $r) { + echo "{$r['name']}: {$r['_distance']} meters\n"; +} +``` + +### withinBBox() + +Find records within a bounding box. + +```php +$inArea = $db->withinBBox( + "restaurants", + "location", + 28.97, 41.00, // minLon, minLat (SW corner) + 29.00, 41.03 // maxLon, maxLat (NE corner) +); +``` + +### nearest() + +Find K nearest records to a point. + +```php +// Find 5 nearest restaurants +$closest = $db->nearest( + "restaurants", + "location", + 28.9784, 41.0082, // reference point + 5 // number of results +); + +// Results are sorted by distance (nearest first) +echo "Nearest: " . $closest[0]['name']; +``` + +**Options:** + +```php +$closest = $db->nearest("restaurants", "location", 28.9784, 41.0082, 5, [ + 'includeDistance' => true, + 'maxDistance' => 10000 // Maximum distance in meters (10km) +]); +``` + +### withinPolygon() + +Find records within a polygon boundary. + +```php +$polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], + [29.00, 41.00], + [29.00, 41.03], + [28.97, 41.03], + [28.97, 41.00] + ]] +]; + +$inPolygon = $db->withinPolygon("restaurants", "location", $polygon); +``` + +--- + +## Query Builder Integration + +All spatial methods are available in the query builder. + +### withinDistance() + +```php +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5000) // 5000 meters + ->get(); +``` + +### withinBBox() + +```php +$results = $db->query("restaurants") + ->withinBBox('location', 28.97, 41.00, 29.00, 41.03) + ->get(); +``` + +### nearest() + +```php +$results = $db->query("restaurants") + ->nearest('location', 28.9784, 41.0082, 10) + ->get(); +``` + +### withinPolygon() + +```php +$results = $db->query("restaurants") + ->withinPolygon('location', $polygon) + ->get(); +``` + +### withDistance() + +Add distance field to results. + +```php +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 10000) // 10000 meters + ->withDistance('location', 28.9784, 41.0082) + ->sort('_distance', 'asc') + ->get(); + +// Each result has _distance field in meters +``` + +--- + +## Combining Spatial with Filters + +The real power comes from combining spatial queries with attribute filters using comparison operators. + +### Spatial + Simple Where + +```php +$openNearby = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 3000) // 3000 meters + ->where(['open_now' => true]) + ->get(); +``` + +### Spatial + Comparison Operators + +```php +// Find highly-rated affordable restaurants nearby +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5000) // 5000 meters + ->where([ + 'rating' => ['$gte' => 4.0], + 'price_range' => ['$lte' => 3], + 'review_count' => ['$gt' => 50] + ]) + ->get(); +``` + +### Spatial + $in/$nin + +```php +// Find nearby Turkish or Italian restaurants +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5000) // 5000 meters + ->where([ + 'cuisine' => ['$in' => ['turkish', 'italian', 'greek']] + ]) + ->get(); + +// Exclude fast food +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5000) // 5000 meters + ->where([ + 'category' => ['$nin' => ['fast_food']] + ]) + ->get(); +``` + +### Spatial + Range Query + +```php +// Mid-range restaurants nearby +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 3000) // 3000 meters + ->where([ + 'price_range' => ['$gte' => 2, '$lte' => 4] + ]) + ->get(); +``` + +### Spatial + $like + +```php +// Find places with "Cafe" in name +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 2000) // 2000 meters + ->where([ + 'name' => ['$like' => 'Cafe'] + ]) + ->get(); +``` + +### Spatial + $exists + +```php +// Find places with delivery available +$results = $db->query("restaurants") + ->withinDistance('location', 28.9784, 41.0082, 5000) // 5000 meters + ->where([ + 'delivery' => true, + 'menu' => ['$exists' => true] + ]) + ->get(); +``` + +### Complex Combined Query + +```php +// Food delivery app: nearby, open, delivers, good rating, affordable +$restaurants = $db->query("restaurants") + ->withinDistance('location', $userLon, $userLat, 5000) // 5000 meters + ->where([ + 'open_now' => true, + 'delivery' => true, + 'rating' => ['$gte' => 4.0], + 'price_range' => ['$lte' => 3], + 'cuisine' => ['$in' => ['turkish', 'italian', 'chinese']], + 'review_count' => ['$gt' => 20] + ]) + ->withDistance('location', $userLon, $userLat) + ->sort('rating', 'desc') + ->limit(20) + ->get(); +``` + +--- + +## Performance Optimization + +### R-tree Index Structure + +noneDB uses an optimized R-tree with: + +- **Node size:** 32 entries per node (reduces tree depth) +- **Linear split algorithm:** O(n) instead of O(n²) +- **Parent pointer map:** O(1) parent lookup +- **Dirty flag pattern:** Batched disk writes + +### Best Practices + +1. **Always create spatial index before queries** + ```php + $db->createSpatialIndex("locations", "coords"); + // Then run queries + ``` + +2. **Use withinDistance for radius search** + ```php + // Good: Uses R-tree to narrow candidates + $db->withinDistance("locations", "coords", $lon, $lat, 10000); // 10000 meters + ``` + +3. **Combine spatial with where for efficiency** + ```php + // Spatial filter first, then attribute filter + $db->query("locations") + ->withinDistance('coords', $lon, $lat, 5000) // 5000 meters, spatial first + ->where(['active' => true]) // Then filter + ->get(); + ``` + +4. **Use withDistance + sort for distance-ordered results** + ```php + $db->query("locations") + ->withinDistance('coords', $lon, $lat, 10000) // 10000 meters + ->withDistance('coords', $lon, $lat) + ->sort('_distance', 'asc') + ->limit(10) + ->get(); + ``` + +5. **Use nearest() for K-nearest queries** + ```php + // More efficient than withinDistance + limit for finding closest + $db->nearest("locations", "coords", $lon, $lat, 5); + ``` + +### Performance Characteristics + +| Operation | Without Index | With R-tree Index | +|-----------|---------------|-------------------| +| withinDistance | O(n) | O(log n + k) | +| withinBBox | O(n) | O(log n + k) | +| nearest | O(n log n) | O(log n + k) | +| withinPolygon | O(n) | O(log n + k) | + +Where n = total records, k = matching records. + +--- + +## Real-World Examples + +### Food Delivery App + +```php +class DeliveryService { + private $db; + + public function findRestaurants($userLat, $userLon, $filters = []) { + $query = $this->db->query("restaurants") + ->withinDistance('location', $userLon, $userLat, 5000) // 5000 meters + ->where([ + 'open_now' => true, + 'delivery' => true, + 'rating' => ['$gte' => 3.5] + ]); + + if (!empty($filters['cuisine'])) { + $query->where(['cuisine' => ['$in' => $filters['cuisine']]]); + } + + if (!empty($filters['maxPrice'])) { + $query->where(['price_range' => ['$lte' => $filters['maxPrice']]]); + } + + return $query + ->withDistance('location', $userLon, $userLat) + ->sort('rating', 'desc') + ->limit(20) + ->get(); + } +} +``` + +### Ride-Sharing App + +```php +class DriverService { + public function findNearestDrivers($passengerLat, $passengerLon, $carType = null) { + $query = $this->db->query("drivers") + ->nearest('current_location', $passengerLon, $passengerLat, 20) + ->where([ + 'status' => 'available', + 'rating' => ['$gte' => 4.0] + ]); + + if ($carType) { + $query->where(['car_type' => $carType]); + } + + return $query + ->withDistance('current_location', $passengerLon, $passengerLat) + ->limit(5) + ->get(); + } +} +``` + +### Real Estate Search + +```php +class PropertyService { + public function searchProperties($searchArea, $filters) { + return $this->db->query("properties") + ->withinPolygon('location', $searchArea) + ->where([ + 'type' => $filters['type'] ?? ['$in' => ['apartment', 'house']], + 'price' => [ + '$gte' => $filters['minPrice'] ?? 0, + '$lte' => $filters['maxPrice'] ?? PHP_INT_MAX + ], + 'bedrooms' => ['$gte' => $filters['minBedrooms'] ?? 1], + 'status' => 'available' + ]) + ->sort('price', 'asc') + ->get(); + } +} +``` + +### Store Locator + +```php +class StoreLocator { + public function findStores($userLat, $userLon, $options = []) { + $radius = $options['radius'] ?? 10000; // Default 10km (10000 meters) + $limit = $options['limit'] ?? 10; + + return $this->db->query("stores") + ->withinDistance('location', $userLon, $userLat, $radius) // meters + ->where([ + 'active' => true, + 'type' => ['$in' => $options['types'] ?? ['retail', 'flagship']] + ]) + ->withDistance('location', $userLon, $userLat) + ->sort('_distance', 'asc') + ->limit($limit) + ->get(); + } +} +``` + +### Geofencing + +```php +class GeofenceService { + public function checkUserInZone($userId, $userLat, $userLon) { + // Get user's assigned zone + $user = $this->db->query("users") + ->where(['key' => $userId]) + ->first(); + + if (!$user || !isset($user['assigned_zone'])) { + return false; + } + + // Check if current location is within zone + $zone = $this->db->query("zones") + ->where(['key' => $user['assigned_zone']]) + ->first(); + + if (!$zone) { + return false; + } + + // Use polygon intersection + $point = ['type' => 'Point', 'coordinates' => [$userLon, $userLat]]; + + return $this->isPointInPolygon($point, $zone['boundary']); + } + + private function isPointInPolygon($point, $polygon) { + // Create temp record + $this->db->insert("temp_check", ['location' => $point]); + $this->db->createSpatialIndex("temp_check", "location"); + + $result = $this->db->withinPolygon("temp_check", "location", $polygon); + + // Cleanup + $this->db->delete("temp_check", []); + $this->db->dropSpatialIndex("temp_check", "location"); + + return count($result) > 0; + } +} +``` + +--- + +## Error Handling + +### Validation + +```php +$validation = $db->validateGeoJSON($geometry); + +if (!$validation['valid']) { + echo "Invalid GeoJSON: " . $validation['error']; +} +``` + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| "Spatial index already exists" | Duplicate createSpatialIndex | Check with hasSpatialIndex first | +| "Spatial index not found" | Query without index | Create index before querying | +| "Invalid GeoJSON" | Malformed geometry | Validate with validateGeoJSON() | +| "Ring must be closed" | Polygon not closed | Ensure first == last point | +| "Invalid longitude" | lon > 180 or < -180 | Use valid coordinates | +| "Invalid latitude" | lat > 90 or < -90 | Use valid coordinates | + +--- + +## Version History + +| Version | Changes | +|---------|---------| +| 3.1.0 | Initial spatial indexing with R-tree | +| 3.1.0 | GeoJSON validation | +| 3.1.0 | withinDistance, withinBBox, nearest, withinPolygon | +| 3.1.0 | Query builder spatial integration | +| 3.1.0 | Performance optimizations (parent pointers, linear split) | diff --git a/noneDB.php b/noneDB.php index 048d8d0..129a6f3 100644 --- a/noneDB.php +++ b/noneDB.php @@ -70,12 +70,23 @@ class noneDB { private static $staticFileExistsCache=[]; // Shared file_exists cache - v3.0.0 private static $staticSanitizeCache=[]; // Shared dbname sanitization cache - v3.0.0 private static $staticFieldIndexCache=[]; // Shared field index cache - v3.0.0 + private static $staticSpatialIndexCache=[]; // Shared spatial R-tree index cache - v3.1.0 + private static $staticGlobalSpatialIndexCache=[]; // Shared global spatial index cache - v3.1.0 private static $staticCacheEnabled=true; // Enable/disable static caching // Field indexing configuration - v3.0.0 private $fieldIndexEnabled = true; // Enable field-based indexing private $fieldIndexCache = []; // Instance-level field index cache + // Spatial indexing configuration - v3.1.0 + private $spatialIndexEnabled = true; // Enable spatial R-tree indexing + private $spatialIndexCache = []; // Instance-level spatial index cache + private $globalSpatialIndexCache = []; // Instance-level global spatial index cache + private $rtreeNodeSize = 32; // Max entries per R-tree node (increased from 16 for fewer splits) + private $dirtySpatialIndexes = []; // Dirty flags for batch spatial writes + private $distanceCache = []; // Haversine distance memoization cache + private $centroidCache = []; // Geometry centroid cache + // Persistent hash cache - v3.0.0 performance optimization private $hashCacheFile = null; // Path to persistent hash cache file private $hashCacheDirty = false; // Track if hash cache needs saving @@ -99,7 +110,12 @@ public function __construct($config = null){ $this->hashCache = &self::$staticHashCache; $this->jsonlFormatCache = &self::$staticFormatCache; $this->fieldIndexCache = &self::$staticFieldIndexCache; + $this->spatialIndexCache = &self::$staticSpatialIndexCache; + $this->globalSpatialIndexCache = &self::$staticGlobalSpatialIndexCache; } + + // Register shutdown function to flush dirty spatial indexes + register_shutdown_function([$this, 'flushSpatialIndexes']); } /** @@ -355,6 +371,8 @@ public static function clearStaticCache(){ self::$staticFileExistsCache = []; self::$staticSanitizeCache = []; self::$staticFieldIndexCache = []; + self::$staticSpatialIndexCache = []; + self::$staticGlobalSpatialIndexCache = []; } /** @@ -2048,9 +2066,9 @@ private function updateJsonlRecord($dbname, $key, $newData, $shardId = null, $sk $path = $this->dbDir . $hash . "-" . $dbname . ".nonedb"; } - // Read old record for field index update + // Read old record for field/spatial index update $oldRecord = null; - if($this->fieldIndexEnabled){ + if($this->fieldIndexEnabled || $this->spatialIndexEnabled){ $location = $index['o'][$key]; $oldRecord = $this->readJsonlRecord($path, $location[0], $location[1]); } @@ -2078,6 +2096,11 @@ private function updateJsonlRecord($dbname, $key, $newData, $shardId = null, $sk $this->updateFieldIndexOnUpdate($dbname, $oldRecord, $newData, $key, $shardId); } + // Update spatial indexes (v3.1.0) + if($this->spatialIndexEnabled && $oldRecord !== null){ + $this->updateSpatialIndexOnUpdate($dbname, $oldRecord, $newData, $key, $shardId); + } + // Check if compaction needed (skip during batch operations) if(!$skipCompaction && $index['d'] > $index['n'] * $this->jsonlGarbageThreshold){ $this->compactJsonl($dbname, $shardId); @@ -2117,6 +2140,8 @@ private function updateJsonlRecordsBatch($dbname, array $updates, $shardId = nul $indexUpdates = []; $updated = 0; + $needRecord = $this->fieldIndexEnabled || $this->spatialIndexEnabled; + foreach($updates as $item){ $key = $item['key']; $newData = $item['data']; @@ -2125,13 +2150,21 @@ private function updateJsonlRecordsBatch($dbname, array $updates, $shardId = nul continue; } - // Read old record for field index update - if($this->fieldIndexEnabled){ + // Read old record for field/spatial index update + $oldRecord = null; + if($needRecord){ $location = $index['o'][$key]; $oldRecord = $this->readJsonlRecord($path, $location[0], $location[1]); - if($oldRecord !== null){ - $this->updateFieldIndexOnUpdate($dbname, $oldRecord, $newData, $key, $shardId); - } + } + + // Update field indexes + if($this->fieldIndexEnabled && $oldRecord !== null){ + $this->updateFieldIndexOnUpdate($dbname, $oldRecord, $newData, $key, $shardId); + } + + // Update spatial indexes (v3.1.0) + if($this->spatialIndexEnabled && $oldRecord !== null){ + $this->updateSpatialIndexOnUpdate($dbname, $oldRecord, $newData, $key, $shardId); } $newData['key'] = $key; @@ -2161,6 +2194,11 @@ private function updateJsonlRecordsBatch($dbname, array $updates, $shardId = nul // Single index write $this->writeJsonlIndex($dbname, $index, $shardId); + // Batch flush spatial indexes (single write instead of N writes) + if($this->spatialIndexEnabled && $updated > 0){ + $this->flushSpatialIndexes(); + } + // Check if compaction needed if($index['d'] > $index['n'] * $this->jsonlGarbageThreshold){ $this->compactJsonl($dbname, $shardId); @@ -2182,9 +2220,9 @@ private function deleteJsonlRecord($dbname, $key, $shardId = null){ return false; } - // Read record for field index update before deletion + // Read record for field/spatial index update before deletion $record = null; - if($this->fieldIndexEnabled){ + if($this->fieldIndexEnabled || $this->spatialIndexEnabled){ if($shardId !== null){ $path = $this->getShardPath($dbname, $shardId); } else { @@ -2205,6 +2243,11 @@ private function deleteJsonlRecord($dbname, $key, $shardId = null){ $this->updateFieldIndexOnDelete($dbname, $record, $key, $shardId); } + // Update spatial indexes (v3.1.0) + if($this->spatialIndexEnabled && $record !== null){ + $this->updateSpatialIndexOnDelete($dbname, $record, $key, $shardId); + } + // Check if compaction needed if($index['d'] > $index['n'] * $this->jsonlGarbageThreshold){ $this->compactJsonl($dbname, $shardId); @@ -2230,9 +2273,10 @@ private function deleteJsonlRecordsBatch($dbname, array $keys, $shardId = null){ return 0; } - // Get path for field index updates + // Get path for field/spatial index updates $path = null; - if($this->fieldIndexEnabled){ + $needRecord = $this->fieldIndexEnabled || $this->spatialIndexEnabled; + if($needRecord){ if($shardId !== null){ $path = $this->getShardPath($dbname, $shardId); } else { @@ -2247,13 +2291,21 @@ private function deleteJsonlRecordsBatch($dbname, array $keys, $shardId = null){ continue; } - // Read record for field index update before deletion - if($this->fieldIndexEnabled && $path !== null){ + // Read record for field/spatial index update before deletion + $record = null; + if($needRecord && $path !== null){ $location = $index['o'][$key]; $record = $this->readJsonlRecord($path, $location[0], $location[1]); - if($record !== null){ - $this->updateFieldIndexOnDelete($dbname, $record, $key, $shardId); - } + } + + // Update field indexes + if($this->fieldIndexEnabled && $record !== null){ + $this->updateFieldIndexOnDelete($dbname, $record, $key, $shardId); + } + + // Update spatial indexes (v3.1.0) + if($this->spatialIndexEnabled && $record !== null){ + $this->updateSpatialIndexOnDelete($dbname, $record, $key, $shardId); } unset($index['o'][$key]); @@ -2264,6 +2316,11 @@ private function deleteJsonlRecordsBatch($dbname, array $keys, $shardId = null){ // Single index write for all deletions $this->writeJsonlIndex($dbname, $index, $shardId); + // Batch flush spatial indexes (single write instead of N writes) + if($this->spatialIndexEnabled && $deleted > 0){ + $this->flushSpatialIndexes(); + } + // Check if compaction needed if($index['d'] > $index['n'] * $this->jsonlGarbageThreshold){ $this->compactJsonl($dbname, $shardId); @@ -3233,6 +3290,18 @@ private function insertShardedDirect($dbname, array $validItems){ $this->updateFieldIndexOnInsert($dbname, $record, $insertedKeys[$i], $shardId); } } + + // Update spatial indexes (v3.1.0) + if($this->spatialIndexEnabled){ + foreach($writeInfo['items'] as $i => $record){ + $this->updateSpatialIndexOnInsert($dbname, $record, $insertedKeys[$i], $shardId); + } + } + } + + // Batch flush spatial indexes after all shards processed (single write per index) + if($this->spatialIndexEnabled){ + $this->flushSpatialIndexes(); } return array("n" => $insertedCount); @@ -4124,6 +4193,15 @@ private function insertDirect($dbname, array $validItems){ } } + // Update spatial indexes for inserted records (v3.1.0) + if($this->spatialIndexEnabled){ + foreach($validItems as $i => $record){ + $this->updateSpatialIndexOnInsert($dbname, $record, $keys[$i], null); + } + // Batch flush spatial indexes (single write instead of N writes) + $this->flushSpatialIndexes(); + } + // Auto-migrate to sharded format if threshold reached if($this->shardingEnabled && $this->autoMigrate && $index['n'] >= $this->shardSize){ $this->migrateToSharded($dbname); @@ -5099,210 +5177,2351 @@ private function updateFieldIndexOnUpdate($dbname, $oldRecord, $newRecord, $key, } // ========================================== - // WRITE BUFFER PUBLIC API + // SPATIAL INDEX - GEOJSON SUPPORT (v3.1.0) // ========================================== /** - * Manually flush buffer for a database - * @param string $dbname - * @return array ['success' => bool, 'flushed' => int, 'error' => string|null] + * Validate a GeoJSON geometry object + * Supports: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection + * + * @param array $geometry GeoJSON geometry object + * @return array ['valid' => bool, 'error' => ?string, 'type' => ?string] */ - public function flush($dbname){ - $dbname = $this->sanitizeDbName($dbname); + public function validateGeoJSON($geometry): array { + if (!is_array($geometry)) { + return ['valid' => false, 'error' => 'Geometry must be an array', 'type' => null]; + } + if (!isset($geometry['type'])) { + return ['valid' => false, 'error' => 'Missing type property', 'type' => null]; + } - if($this->isSharded($dbname)){ - $result = $this->flushAllShardBuffers($dbname); - return ['success' => true, 'flushed' => $result['flushed'], 'error' => null]; - } else { - return $this->flushBufferToMain($dbname); + $type = $geometry['type']; + + // GeometryCollection has different structure + if ($type === 'GeometryCollection') { + return $this->validateGeometryCollection($geometry); + } + + if (!isset($geometry['coordinates'])) { + return ['valid' => false, 'error' => 'Missing coordinates property', 'type' => null]; + } + + $coords = $geometry['coordinates']; + + if (!is_array($coords)) { + return ['valid' => false, 'error' => 'Coordinates must be an array', 'type' => null]; + } + + switch ($type) { + case 'Point': + return $this->validateGeoJSONPoint($coords); + case 'LineString': + return $this->validateGeoJSONLineString($coords); + case 'Polygon': + return $this->validateGeoJSONPolygon($coords); + case 'MultiPoint': + return $this->validateGeoJSONMultiPoint($coords); + case 'MultiLineString': + return $this->validateGeoJSONMultiLineString($coords); + case 'MultiPolygon': + return $this->validateGeoJSONMultiPolygon($coords); + default: + return ['valid' => false, 'error' => "Unknown geometry type: $type", 'type' => null]; } } /** - * Flush all buffers for all known databases - * Called automatically on shutdown if bufferAutoFlushOnShutdown is true - * @return array ['databases' => int, 'flushed' => int] + * Validate Point coordinates [lon, lat] or [lon, lat, altitude] */ - public function flushAllBuffers(){ - $dbDir = $this->dbDir; - $totalFlushed = 0; - $dbCount = 0; - - // Find all buffer files - $bufferFiles = glob($dbDir . '*.buffer'); - if($bufferFiles === false){ - $bufferFiles = []; + private function validateGeoJSONPoint(array $coords): array { + if (!is_array($coords) || count($coords) < 2) { + return ['valid' => false, 'error' => 'Point must have at least 2 coordinates', 'type' => null]; + } + if (!is_numeric($coords[0]) || !is_numeric($coords[1])) { + return ['valid' => false, 'error' => 'Point coordinates must be numeric', 'type' => null]; + } + $lon = (float)$coords[0]; + $lat = (float)$coords[1]; + if ($lon < -180 || $lon > 180) { + return ['valid' => false, 'error' => 'Longitude must be between -180 and 180', 'type' => null]; } + if ($lat < -90 || $lat > 90) { + return ['valid' => false, 'error' => 'Latitude must be between -90 and 90', 'type' => null]; + } + return ['valid' => true, 'error' => null, 'type' => 'Point']; + } - // Track which databases we've processed - $processedDbs = []; + /** + * Validate LineString coordinates [[lon,lat], [lon,lat], ...] + */ + private function validateGeoJSONLineString(array $coords): array { + if (!is_array($coords) || count($coords) < 2) { + return ['valid' => false, 'error' => 'LineString must have at least 2 positions', 'type' => null]; + } + foreach ($coords as $i => $position) { + $result = $this->validateGeoJSONPoint($position); + if (!$result['valid']) { + return ['valid' => false, 'error' => "Invalid position at index $i: " . $result['error'], 'type' => null]; + } + } + return ['valid' => true, 'error' => null, 'type' => 'LineString']; + } - foreach($bufferFiles as $bufferFile){ - $basename = basename($bufferFile); + /** + * Validate Polygon coordinates [[[lon,lat], ...], ...] + * First ring is outer boundary, subsequent rings are holes + */ + private function validateGeoJSONPolygon(array $coords): array { + if (!is_array($coords) || count($coords) < 1) { + return ['valid' => false, 'error' => 'Polygon must have at least one ring', 'type' => null]; + } + foreach ($coords as $ringIndex => $ring) { + if (!is_array($ring) || count($ring) < 4) { + return ['valid' => false, 'error' => "Ring $ringIndex must have at least 4 positions", 'type' => null]; + } + // Validate each position + foreach ($ring as $i => $position) { + $result = $this->validateGeoJSONPoint($position); + if (!$result['valid']) { + return ['valid' => false, 'error' => "Invalid position at ring $ringIndex, index $i: " . $result['error'], 'type' => null]; + } + } + // Check if ring is closed (first == last) + $first = $ring[0]; + $last = $ring[count($ring) - 1]; + if ($first[0] !== $last[0] || $first[1] !== $last[1]) { + return ['valid' => false, 'error' => "Ring $ringIndex must be closed (first and last positions must be identical)", 'type' => null]; + } + } + return ['valid' => true, 'error' => null, 'type' => 'Polygon']; + } - // Extract database name from buffer file name - // Format: hash-dbname.nonedb.buffer or hash-dbname_s0.nonedb.buffer - if(preg_match('/^[a-f0-9]+-(.+?)(?:_s\d+)?\.nonedb\.buffer$/', $basename, $matches)){ - $dbname = $matches[1]; + /** + * Validate MultiPoint coordinates [[lon,lat], [lon,lat], ...] + */ + private function validateGeoJSONMultiPoint(array $coords): array { + if (!is_array($coords) || count($coords) < 1) { + return ['valid' => false, 'error' => 'MultiPoint must have at least one point', 'type' => null]; + } + foreach ($coords as $i => $position) { + $result = $this->validateGeoJSONPoint($position); + if (!$result['valid']) { + return ['valid' => false, 'error' => "Invalid point at index $i: " . $result['error'], 'type' => null]; + } + } + return ['valid' => true, 'error' => null, 'type' => 'MultiPoint']; + } - // Avoid processing same DB multiple times - if(isset($processedDbs[$dbname])){ - continue; - } - $processedDbs[$dbname] = true; + /** + * Validate MultiLineString coordinates [[[lon,lat], ...], ...] + */ + private function validateGeoJSONMultiLineString(array $coords): array { + if (!is_array($coords) || count($coords) < 1) { + return ['valid' => false, 'error' => 'MultiLineString must have at least one linestring', 'type' => null]; + } + foreach ($coords as $i => $lineString) { + $result = $this->validateGeoJSONLineString($lineString); + if (!$result['valid']) { + return ['valid' => false, 'error' => "Invalid linestring at index $i: " . $result['error'], 'type' => null]; + } + } + return ['valid' => true, 'error' => null, 'type' => 'MultiLineString']; + } - // Check if sharded or non-sharded - if($this->isSharded($dbname)){ - $result = $this->flushAllShardBuffers($dbname); - $totalFlushed += $result['flushed']; - } else { - $result = $this->flushBufferToMain($dbname); - if($result['success']){ - $totalFlushed += $result['flushed']; - } - } - $dbCount++; + /** + * Validate MultiPolygon coordinates [[[[lon,lat], ...], ...], ...] + */ + private function validateGeoJSONMultiPolygon(array $coords): array { + if (!is_array($coords) || count($coords) < 1) { + return ['valid' => false, 'error' => 'MultiPolygon must have at least one polygon', 'type' => null]; + } + foreach ($coords as $i => $polygon) { + $result = $this->validateGeoJSONPolygon($polygon); + if (!$result['valid']) { + return ['valid' => false, 'error' => "Invalid polygon at index $i: " . $result['error'], 'type' => null]; } } + return ['valid' => true, 'error' => null, 'type' => 'MultiPolygon']; + } - return ['databases' => $dbCount, 'flushed' => $totalFlushed]; + /** + * Validate GeometryCollection + */ + private function validateGeometryCollection(array $geometry): array { + if (!isset($geometry['geometries']) || !is_array($geometry['geometries'])) { + return ['valid' => false, 'error' => 'GeometryCollection must have geometries array', 'type' => null]; + } + foreach ($geometry['geometries'] as $i => $geom) { + if (!is_array($geom)) { + return ['valid' => false, 'error' => "Invalid geometry at index $i: must be an object", 'type' => null]; + } + $result = $this->validateGeoJSON($geom); + if (!$result['valid']) { + return ['valid' => false, 'error' => "Invalid geometry at index $i: " . $result['error'], 'type' => null]; + } + } + return ['valid' => true, 'error' => null, 'type' => 'GeometryCollection']; } + // ========================================== + // SPATIAL INDEX - GEOMETRY OPERATIONS + // ========================================== + /** - * Get buffer information for a database - * @param string $dbname - * @return array Buffer statistics + * Calculate Minimum Bounding Rectangle (MBR) for any GeoJSON geometry + * @param array $geometry GeoJSON geometry object + * @return array [minLon, minLat, maxLon, maxLat] */ - public function getBufferInfo($dbname){ - $dbname = $this->sanitizeDbName($dbname); + private function calculateMBR(array $geometry): array { + $type = $geometry['type'] ?? ''; + $coords = $geometry['coordinates'] ?? []; - $info = [ - 'enabled' => $this->bufferEnabled, - 'sizeLimit' => $this->bufferSizeLimit, - 'countLimit' => $this->bufferCountLimit, - 'flushInterval' => $this->bufferFlushInterval, - 'buffers' => [] - ]; + switch ($type) { + case 'Point': + return [$coords[0], $coords[1], $coords[0], $coords[1]]; - if($this->isSharded($dbname)){ - $meta = $this->getCachedMeta($dbname); - if($meta !== null && isset($meta['shards'])){ - foreach($meta['shards'] as $shard){ - $bufferPath = $this->getShardBufferPath($dbname, $shard['id']); - $info['buffers']['shard_' . $shard['id']] = [ - 'exists' => $this->hasBuffer($bufferPath), - 'size' => $this->getBufferSize($bufferPath), - 'records' => $this->hasBuffer($bufferPath) ? $this->getBufferRecordCount($bufferPath) : 0 - ]; + case 'LineString': + case 'MultiPoint': + return $this->mbrFromPoints($coords); + + case 'Polygon': + // Use outer ring (first ring) + return $this->mbrFromPoints($coords[0]); + + case 'MultiLineString': + $allPoints = []; + foreach ($coords as $lineString) { + $allPoints = array_merge($allPoints, $lineString); } - } - } else { - $bufferPath = $this->getBufferPath($dbname); - $info['buffers']['main'] = [ - 'exists' => $this->hasBuffer($bufferPath), - 'size' => $this->getBufferSize($bufferPath), - 'records' => $this->hasBuffer($bufferPath) ? $this->getBufferRecordCount($bufferPath) : 0 - ]; - } + return $this->mbrFromPoints($allPoints); - return $info; + case 'MultiPolygon': + $allPoints = []; + foreach ($coords as $polygon) { + $allPoints = array_merge($allPoints, $polygon[0]); // outer ring + } + return $this->mbrFromPoints($allPoints); + + case 'GeometryCollection': + $mbr = null; + foreach ($geometry['geometries'] as $geom) { + $geomMBR = $this->calculateMBR($geom); + $mbr = $mbr === null ? $geomMBR : $this->mbrUnion($mbr, $geomMBR); + } + return $mbr ?? [0, 0, 0, 0]; + + default: + return [0, 0, 0, 0]; + } } /** - * Enable or disable write buffering - * @param bool $enable + * Calculate MBR from array of points + * @param array $points Array of [lon, lat] positions + * @return array [minLon, minLat, maxLon, maxLat] */ - public function enableBuffering($enable = true){ - $this->bufferEnabled = (bool)$enable; + private function mbrFromPoints(array $points): array { + if (empty($points)) { + return [0, 0, 0, 0]; + } + + $minLon = $maxLon = $points[0][0]; + $minLat = $maxLat = $points[0][1]; + + foreach ($points as $point) { + $minLon = min($minLon, $point[0]); + $maxLon = max($maxLon, $point[0]); + $minLat = min($minLat, $point[1]); + $maxLat = max($maxLat, $point[1]); + } + + return [$minLon, $minLat, $maxLon, $maxLat]; } /** - * Check if buffering is enabled - * @return bool + * Union two MBRs + * @param array $mbr1 [minLon, minLat, maxLon, maxLat] + * @param array $mbr2 [minLon, minLat, maxLon, maxLat] + * @return array [minLon, minLat, maxLon, maxLat] */ - public function isBufferingEnabled(){ - return $this->bufferEnabled; + private function mbrUnion(array $mbr1, array $mbr2): array { + return [ + min($mbr1[0], $mbr2[0]), // minLon + min($mbr1[1], $mbr2[1]), // minLat + max($mbr1[2], $mbr2[2]), // maxLon + max($mbr1[3], $mbr2[3]) // maxLat + ]; } /** - * Set buffer size limit (in bytes) - * @param int $bytes + * Check if two MBRs overlap + * @param array $mbr1 [minLon, minLat, maxLon, maxLat] + * @param array $mbr2 [minLon, minLat, maxLon, maxLat] + * @return bool */ - public function setBufferSizeLimit($bytes){ - $this->bufferSizeLimit = max(1024, (int)$bytes); // Minimum 1KB + private function mbrOverlaps(array $mbr1, array $mbr2): bool { + // Check if one MBR is completely to the left, right, above, or below the other + if ($mbr1[2] < $mbr2[0] || $mbr2[2] < $mbr1[0]) return false; // No horizontal overlap + if ($mbr1[3] < $mbr2[1] || $mbr2[3] < $mbr1[1]) return false; // No vertical overlap + return true; } /** - * Set buffer flush interval (in seconds) - * @param int $seconds 0 to disable time-based flush + * Calculate area of an MBR (in degrees squared) + * @param array $mbr [minLon, minLat, maxLon, maxLat] + * @return float */ - public function setBufferFlushInterval($seconds){ - $this->bufferFlushInterval = max(0, (int)$seconds); + private function mbrArea(array $mbr): float { + $width = $mbr[2] - $mbr[0]; + $height = $mbr[3] - $mbr[1]; + return $width * $height; } /** - * Set buffer count limit - * @param int $count + * Calculate enlargement needed if entry is added to an MBR + * @param array $mbr Existing MBR + * @param array $entryMBR New entry's MBR + * @return float Area increase */ - public function setBufferCountLimit($count){ - $this->bufferCountLimit = max(10, (int)$count); // Minimum 10 records + private function mbrEnlargement(array $mbr, array $entryMBR): float { + $unionMBR = $this->mbrUnion($mbr, $entryMBR); + return $this->mbrArea($unionMBR) - $this->mbrArea($mbr); } /** - * Compact a database by removing null entries - * Works for both sharded and non-sharded databases - * @param string $dbname - * @return array + * Calculate Haversine distance between two points (in meters) + * Uses memoization cache for repeated calculations + * @param float $lon1 Longitude of point 1 + * @param float $lat1 Latitude of point 1 + * @param float $lon2 Longitude of point 2 + * @param float $lat2 Latitude of point 2 + * @return float Distance in meters */ - public function compact($dbname){ - $dbname = $this->sanitizeDbName($dbname); - $result = array("success" => false, "freedSlots" => 0); - - // Handle non-sharded database - if(!$this->isSharded($dbname)){ - $hash = $this->hashDBName($dbname); - $fullDBPath = $this->dbDir . $hash . "-" . $dbname . ".nonedb"; + public function haversineDistance(float $lon1, float $lat1, float $lon2, float $lat2): float { + // Check cache first (memoization) + $key = "$lon1,$lat1,$lon2,$lat2"; + if (isset($this->distanceCache[$key])) { + return $this->distanceCache[$key]; + } - if(!$this->cachedFileExists($fullDBPath)){ - $result['status'] = 'database_not_found'; - return $result; - } + $earthRadius = 6371000.0; // meters - // Ensure JSONL format (auto-migrate v2 if needed) - $this->ensureJsonlFormat($dbname); + $latDelta = deg2rad($lat2 - $lat1); + $lonDelta = deg2rad($lon2 - $lon1); - $index = $this->readJsonlIndex($dbname); - if($index === null){ - $result['status'] = 'read_error'; - return $result; - } + $a = sin($latDelta / 2) * sin($latDelta / 2) + + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * + sin($lonDelta / 2) * sin($lonDelta / 2); - $freedSlots = $index['d']; // Dirty count = freed slots - $totalRecords = count($index['o']); // Active records in index + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); - $compactResult = $this->compactJsonl($dbname); + $distance = $earthRadius * $c; - $result['success'] = true; - $result['freedSlots'] = $freedSlots; - $result['totalRecords'] = $totalRecords; - $result['sharded'] = false; - return $result; + // Cache result (limit cache size to prevent memory issues) + if (count($this->distanceCache) < 10000) { + $this->distanceCache[$key] = $distance; } - // Handle sharded database - $meta = $this->getCachedMeta($dbname); - if($meta === null){ - $result['status'] = 'meta_read_error'; - return $result; - } + return $distance; + } - $allRecords = []; - // Use meta's deletedCount for freedSlots (JSONL index 'd' may be 0 after auto-compaction) - $freedSlots = $meta['deletedCount'] ?? 0; + /** + * Clear distance cache (call after each query for memory management) + */ + public function clearDistanceCache(): void { + $this->distanceCache = []; + } - // v3.0.0: Collect all non-null records from all shards (JSONL format) - foreach($meta['shards'] as $shard){ - $shardId = $shard['id']; + /** + * Convert a circle (center + radius) to a bounding box + * @param float $lon Center longitude + * @param float $lat Center latitude + * @param float $radiusMeters Radius in meters + * @return array [minLon, minLat, maxLon, maxLat] + */ + private function circleToBBox(float $lon, float $lat, float $radiusMeters): array { + // Approximate degrees per meter at this latitude + $latDeg = $radiusMeters / 111000.0; // 1 degree lat ~ 111 km = 111000 m + $lonDeg = $radiusMeters / (111000.0 * cos(deg2rad($lat))); + + // Handle edge case near poles + if (abs($lat) > 89) { + $lonDeg = 360; // Cover all longitudes near poles + } + + return [ + max(-180, $lon - $lonDeg), // minLon + max(-90, $lat - $latDeg), // minLat + min(180, $lon + $lonDeg), // maxLon + min(90, $lat + $latDeg) // maxLat + ]; + } + + /** + * Point-in-Polygon test using Winding Number algorithm + * More robust than ray casting for edge cases + * + * @param float $lon Point longitude + * @param float $lat Point latitude + * @param array $polygon GeoJSON Polygon coordinates [[[lon,lat], ...], ...] + * @return bool + */ + private function pointInPolygon(float $lon, float $lat, array $polygon): bool { + // Handle GeoJSON Polygon object or raw coordinates + $coords = isset($polygon['coordinates']) ? $polygon['coordinates'] : $polygon; + + // Check outer ring + if (!$this->pointInRing($lon, $lat, $coords[0])) { + return false; + } + + // Check holes (if any) + for ($i = 1; $i < count($coords); $i++) { + if ($this->pointInRing($lon, $lat, $coords[$i])) { + return false; // Inside a hole + } + } + + return true; + } + + /** + * Check if point is inside a ring using Winding Number algorithm + */ + private function pointInRing(float $lon, float $lat, array $ring): bool { + $windingNumber = 0; + $n = count($ring); + + for ($i = 0; $i < $n - 1; $i++) { + $x1 = $ring[$i][0]; + $y1 = $ring[$i][1]; + $x2 = $ring[$i + 1][0]; + $y2 = $ring[$i + 1][1]; + + if ($y1 <= $lat) { + if ($y2 > $lat) { + // Upward crossing + if ($this->isLeftOfLine($x1, $y1, $x2, $y2, $lon, $lat) > 0) { + $windingNumber++; + } + } + } else { + if ($y2 <= $lat) { + // Downward crossing + if ($this->isLeftOfLine($x1, $y1, $x2, $y2, $lon, $lat) < 0) { + $windingNumber--; + } + } + } + } + + return $windingNumber !== 0; + } + + /** + * Determine if point is left of a line (using cross product) + * Returns: >0 if left, <0 if right, =0 if on line + */ + private function isLeftOfLine(float $x1, float $y1, float $x2, float $y2, float $px, float $py): float { + return (($x2 - $x1) * ($py - $y1)) - (($px - $x1) * ($y2 - $y1)); + } + + /** + * Check if two line segments intersect + * @param array $p1 First point of segment 1 [lon, lat] + * @param array $p2 Second point of segment 1 [lon, lat] + * @param array $p3 First point of segment 2 [lon, lat] + * @param array $p4 Second point of segment 2 [lon, lat] + * @return bool + */ + private function lineSegmentsIntersect(array $p1, array $p2, array $p3, array $p4): bool { + $d1 = $this->crossProductDirection($p3, $p4, $p1); + $d2 = $this->crossProductDirection($p3, $p4, $p2); + $d3 = $this->crossProductDirection($p1, $p2, $p3); + $d4 = $this->crossProductDirection($p1, $p2, $p4); + + if ((($d1 > 0 && $d2 < 0) || ($d1 < 0 && $d2 > 0)) && + (($d3 > 0 && $d4 < 0) || ($d3 < 0 && $d4 > 0))) { + return true; + } + + // Check collinear cases + if ($d1 == 0 && $this->onSegment($p3, $p4, $p1)) return true; + if ($d2 == 0 && $this->onSegment($p3, $p4, $p2)) return true; + if ($d3 == 0 && $this->onSegment($p1, $p2, $p3)) return true; + if ($d4 == 0 && $this->onSegment($p1, $p2, $p4)) return true; + + return false; + } + + /** + * Cross product direction calculation + */ + private function crossProductDirection(array $p1, array $p2, array $p3): float { + return ($p3[0] - $p1[0]) * ($p2[1] - $p1[1]) - ($p2[0] - $p1[0]) * ($p3[1] - $p1[1]); + } + + /** + * Check if point p is on segment p1-p2 + */ + private function onSegment(array $p1, array $p2, array $p): bool { + return min($p1[0], $p2[0]) <= $p[0] && $p[0] <= max($p1[0], $p2[0]) && + min($p1[1], $p2[1]) <= $p[1] && $p[1] <= max($p1[1], $p2[1]); + } + + /** + * Check if two polygons intersect + * @param array $poly1 First polygon coordinates + * @param array $poly2 Second polygon coordinates + * @return bool + */ + private function polygonsIntersect(array $poly1, array $poly2): bool { + // Handle GeoJSON Polygon objects or raw coordinates + $coords1 = isset($poly1['coordinates']) ? $poly1['coordinates'] : $poly1; + $coords2 = isset($poly2['coordinates']) ? $poly2['coordinates'] : $poly2; + + // 1. Quick MBR rejection test + $mbr1 = $this->mbrFromPoints($coords1[0]); + $mbr2 = $this->mbrFromPoints($coords2[0]); + if (!$this->mbrOverlaps($mbr1, $mbr2)) { + return false; + } + + // 2. Check if any vertex of poly1 is inside poly2 + foreach ($coords1[0] as $point) { + if ($this->pointInPolygon($point[0], $point[1], $coords2)) { + return true; + } + } + + // 3. Check if any vertex of poly2 is inside poly1 + foreach ($coords2[0] as $point) { + if ($this->pointInPolygon($point[0], $point[1], $coords1)) { + return true; + } + } + + // 4. Check edge intersections + $ring1 = $coords1[0]; + $ring2 = $coords2[0]; + + for ($i = 0; $i < count($ring1) - 1; $i++) { + for ($j = 0; $j < count($ring2) - 1; $j++) { + if ($this->lineSegmentsIntersect($ring1[$i], $ring1[$i + 1], $ring2[$j], $ring2[$j + 1])) { + return true; + } + } + } + + return false; + } + + /** + * Get centroid of a geometry (for distance calculations) + * Uses caching for repeated geometry lookups + * @param array $geometry GeoJSON geometry + * @return array [lon, lat] + */ + private function getGeometryCentroid(array $geometry): array { + // Check cache first (use JSON hash as key) + $cacheKey = md5(json_encode($geometry)); + if (isset($this->centroidCache[$cacheKey])) { + return $this->centroidCache[$cacheKey]; + } + + $type = $geometry['type'] ?? ''; + $coords = $geometry['coordinates'] ?? []; + $result = null; + + switch ($type) { + case 'Point': + $result = [$coords[0], $coords[1]]; + break; + + case 'LineString': + case 'MultiPoint': + $sumLon = $sumLat = 0; + foreach ($coords as $point) { + $sumLon += $point[0]; + $sumLat += $point[1]; + } + $n = count($coords); + $result = [$sumLon / $n, $sumLat / $n]; + break; + + case 'Polygon': + // Centroid of outer ring + $ring = $coords[0]; + $sumLon = $sumLat = 0; + $n = count($ring) - 1; // Exclude closing point + for ($i = 0; $i < $n; $i++) { + $sumLon += $ring[$i][0]; + $sumLat += $ring[$i][1]; + } + $result = [$sumLon / $n, $sumLat / $n]; + break; + + default: + // For complex types, use MBR center + $mbr = $this->calculateMBR($geometry); + $result = [($mbr[0] + $mbr[2]) / 2, ($mbr[1] + $mbr[3]) / 2]; + } + + // Cache result (limit size) + if (count($this->centroidCache) < 5000) { + $this->centroidCache[$cacheKey] = $result; + } + + return $result; + } + + /** + * Clear centroid cache + */ + public function clearCentroidCache(): void { + $this->centroidCache = []; + } + + // ========================================== + // SPATIAL INDEX - R-TREE ENGINE + // ========================================== + + /** + * Get path to spatial index file + * @param string $dbname Database name + * @param string $field Field name + * @param int|null $shardId Shard ID (null for non-sharded) + * @return string File path + */ + private function getSpatialIndexPath(string $dbname, string $field, ?int $shardId = null): string { + $hash = $this->hashDBName($dbname); + $field = preg_replace('/[^a-zA-Z0-9_]/', '', $field); + + if ($shardId !== null) { + return $this->dbDir . $hash . '_s' . $shardId . '.nonedb.sidx.' . $field; + } + return $this->dbDir . $hash . '.nonedb.sidx.' . $field; + } + + /** + * Get path to global spatial index file (shard MBR metadata) + * @param string $dbname Database name + * @param string $field Field name + * @return string File path + */ + private function getGlobalSpatialIndexPath(string $dbname, string $field): string { + $hash = $this->hashDBName($dbname); + $field = preg_replace('/[^a-zA-Z0-9_]/', '', $field); + return $this->dbDir . $hash . '.nonedb.gsidx.' . $field; + } + + /** + * Get spatial index cache key + */ + private function getSpatialIndexCacheKey(string $dbname, string $field, ?int $shardId = null): string { + if ($shardId !== null) { + return $dbname . ':' . $field . ':s' . $shardId; + } + return $dbname . ':' . $field; + } + + /** + * Read spatial index from cache or disk + * @param string $dbname Database name + * @param string $field Field name + * @param int|null $shardId Shard ID + * @return array|null Index data or null if not exists + */ + private function readSpatialIndex(string $dbname, string $field, ?int $shardId = null): ?array { + $cacheKey = $this->getSpatialIndexCacheKey($dbname, $field, $shardId); + + // Check cache first + if (isset($this->spatialIndexCache[$cacheKey])) { + return $this->spatialIndexCache[$cacheKey]; + } + + $path = $this->getSpatialIndexPath($dbname, $field, $shardId); + if (!file_exists($path)) { + return null; + } + + $content = $this->atomicRead($path); + if ($content === false) { + return null; + } + + $index = json_decode($content, true); + if ($index === null) { + return null; + } + + // Cache it + $this->spatialIndexCache[$cacheKey] = $index; + + return $index; + } + + /** + * Write spatial index to disk and update cache + * @param string $dbname Database name + * @param string $field Field name + * @param array $index Index data + * @param int|null $shardId Shard ID + * @return bool Success + */ + private function writeSpatialIndex(string $dbname, string $field, array $index, ?int $shardId = null): bool { + $path = $this->getSpatialIndexPath($dbname, $field, $shardId); + $index['updated'] = time(); + + $result = $this->atomicWrite($path, json_encode($index)); + + if ($result) { + $cacheKey = $this->getSpatialIndexCacheKey($dbname, $field, $shardId); + $this->spatialIndexCache[$cacheKey] = $index; + } + + return $result; + } + + /** + * Mark spatial index as dirty (for batch write pattern) + * Updates cache immediately but defers disk write + * @param string $dbname Database name + * @param string $field Field name + * @param array $index Updated index data + * @param int|null $shardId Shard ID + */ + private function markSpatialIndexDirty(string $dbname, string $field, array $index, ?int $shardId = null): void { + $cacheKey = $this->getSpatialIndexCacheKey($dbname, $field, $shardId); + + // Update cache immediately + $this->spatialIndexCache[$cacheKey] = $index; + + // Mark as dirty for later flush + $this->dirtySpatialIndexes[$cacheKey] = [ + 'dbname' => $dbname, + 'field' => $field, + 'shardId' => $shardId + ]; + } + + /** + * Flush all dirty spatial indexes to disk + * Call this after batch operations for optimal I/O performance + * @return int Number of indexes flushed + */ + public function flushSpatialIndexes(): int { + $flushed = 0; + + foreach ($this->dirtySpatialIndexes as $cacheKey => $meta) { + if (isset($this->spatialIndexCache[$cacheKey])) { + $this->writeSpatialIndex( + $meta['dbname'], + $meta['field'], + $this->spatialIndexCache[$cacheKey], + $meta['shardId'] + ); + $flushed++; + } + } + + $this->dirtySpatialIndexes = []; + return $flushed; + } + + /** + * Read global spatial index (shard MBR metadata) + */ + private function readGlobalSpatialIndex(string $dbname, string $field): ?array { + $cacheKey = 'gsidx:' . $dbname . ':' . $field; + + if (isset($this->globalSpatialIndexCache[$cacheKey])) { + return $this->globalSpatialIndexCache[$cacheKey]; + } + + $path = $this->getGlobalSpatialIndexPath($dbname, $field); + if (!file_exists($path)) { + return null; + } + + $content = $this->atomicRead($path); + if ($content === false) { + return null; + } + + $index = json_decode($content, true); + if ($index === null) { + return null; + } + + $this->globalSpatialIndexCache[$cacheKey] = $index; + return $index; + } + + /** + * Write global spatial index + */ + private function writeGlobalSpatialIndex(string $dbname, string $field, array $index): bool { + $path = $this->getGlobalSpatialIndexPath($dbname, $field); + $index['updated'] = time(); + + $result = $this->atomicWrite($path, json_encode($index)); + + if ($result) { + $cacheKey = 'gsidx:' . $dbname . ':' . $field; + $this->globalSpatialIndexCache[$cacheKey] = $index; + } + + return $result; + } + + /** + * Create a new empty R-tree index structure + * @param string $field Field name + * @return array Empty index structure + */ + private function createEmptyRTreeIndex(string $field): array { + return [ + 'v' => 2, + 'field' => $field, + 'type' => 'rtree', + 'nodeSize' => $this->rtreeNodeSize, + 'created' => time(), + 'updated' => time(), + 'rootId' => 0, + 'nextNodeId' => 1, + 'nodes' => [ + '0' => [ + 'isLeaf' => true, + 'mbr' => null, + 'entries' => [], + 'parent' => null + ] + ] + ]; + } + + /** + * Insert an entry into R-tree + * @param string $dbname Database name + * @param string $field Field name + * @param int $key Record key + * @param array $geometry GeoJSON geometry + * @param int|null $shardId Shard ID + * @return bool Success + */ + private function rtreeInsert(string $dbname, string $field, int $key, array $geometry, ?int $shardId = null): bool { + $index = $this->readSpatialIndex($dbname, $field, $shardId); + + if ($index === null) { + $index = $this->createEmptyRTreeIndex($field); + } + + $mbr = $this->calculateMBR($geometry); + $entry = [ + 'key' => $key, + 'mbr' => $mbr, + 'type' => $geometry['type'] + ]; + + // Find leaf node to insert + $leafId = $this->rtreeChooseLeaf($index, $mbr); + $leaf = &$index['nodes'][$leafId]; + + // Add entry to leaf + $leaf['entries'][] = $entry; + + // Update leaf MBR + $leaf['mbr'] = $leaf['mbr'] === null ? $mbr : $this->mbrUnion($leaf['mbr'], $mbr); + + // Check if split is needed + if (count($leaf['entries']) > $index['nodeSize']) { + $this->rtreeSplitNode($index, $leafId); + } + + // Propagate MBR changes up the tree + $this->rtreeAdjustTree($index, $leafId); + + // Mark dirty for batch write (instead of immediate write) + $this->markSpatialIndexDirty($dbname, $field, $index, $shardId); + return true; + } + + /** + * Choose the best leaf node for inserting an entry + * @param array $index R-tree index + * @param array $entryMBR Entry's MBR + * @return string Leaf node ID + */ + private function rtreeChooseLeaf(array &$index, array $entryMBR): string { + $nodeId = (string)$index['rootId']; + + while (!$index['nodes'][$nodeId]['isLeaf']) { + $node = $index['nodes'][$nodeId]; + $minEnlargement = PHP_FLOAT_MAX; + $bestChild = null; + + foreach ($node['children'] as $childId) { + $childMBR = $index['nodes'][$childId]['mbr']; + if ($childMBR === null) { + $bestChild = $childId; + break; + } + + $enlargement = $this->mbrEnlargement($childMBR, $entryMBR); + + if ($enlargement < $minEnlargement) { + $minEnlargement = $enlargement; + $bestChild = $childId; + } elseif ($enlargement == $minEnlargement) { + // Tie-breaker: choose smaller area + if ($this->mbrArea($childMBR) < $this->mbrArea($index['nodes'][$bestChild]['mbr'] ?? [0,0,0,0])) { + $bestChild = $childId; + } + } + } + + $nodeId = (string)$bestChild; + } + + return $nodeId; + } + + /** + * Split an overflowing node using Linear Split algorithm (O(n) seed selection) + * @param array &$index R-tree index (modified in place) + * @param string $nodeId Node to split + */ + private function rtreeSplitNode(array &$index, string $nodeId): void { + $node = $index['nodes'][$nodeId]; + $isLeaf = $node['isLeaf']; + $entries = $isLeaf ? $node['entries'] : $node['children']; + $n = count($entries); + + if ($n <= $index['nodeSize']) { + return; // No split needed + } + + // Linear Split: O(n) seed selection using maximum separation + // Find extremes along each dimension in single pass + $minLon = $maxLon = $minLat = $maxLat = null; + $minLonIdx = $maxLonIdx = $minLatIdx = $maxLatIdx = 0; + $globalMinLon = PHP_FLOAT_MAX; + $globalMaxLon = -PHP_FLOAT_MAX; + $globalMinLat = PHP_FLOAT_MAX; + $globalMaxLat = -PHP_FLOAT_MAX; + + for ($i = 0; $i < $n; $i++) { + $mbr = $isLeaf ? $entries[$i]['mbr'] : $index['nodes'][$entries[$i]]['mbr']; + if ($mbr === null) continue; + + // Track global bounds for normalization + $globalMinLon = min($globalMinLon, $mbr[0]); + $globalMaxLon = max($globalMaxLon, $mbr[2]); + $globalMinLat = min($globalMinLat, $mbr[1]); + $globalMaxLat = max($globalMaxLat, $mbr[3]); + + // Find entry with highest low-side (minLon, minLat) + if ($minLon === null || $mbr[0] > $minLon) { + $minLon = $mbr[0]; + $minLonIdx = $i; + } + if ($minLat === null || $mbr[1] > $minLat) { + $minLat = $mbr[1]; + $minLatIdx = $i; + } + + // Find entry with lowest high-side (maxLon, maxLat) + if ($maxLon === null || $mbr[2] < $maxLon) { + $maxLon = $mbr[2]; + $maxLonIdx = $i; + } + if ($maxLat === null || $mbr[3] < $maxLat) { + $maxLat = $mbr[3]; + $maxLatIdx = $i; + } + } + + // Calculate normalized separation for each dimension + $lonWidth = $globalMaxLon - $globalMinLon; + $latWidth = $globalMaxLat - $globalMinLat; + + $lonSep = ($lonWidth > 0) ? ($minLon - $maxLon) / $lonWidth : 0; + $latSep = ($latWidth > 0) ? ($minLat - $maxLat) / $latWidth : 0; + + // Choose seeds from dimension with greatest separation + if ($lonSep >= $latSep) { + $seed1 = $minLonIdx; + $seed2 = $maxLonIdx; + } else { + $seed1 = $minLatIdx; + $seed2 = $maxLatIdx; + } + + // Ensure seeds are different + if ($seed1 === $seed2) { + $seed2 = ($seed1 + 1) % $n; + } + + // Initialize two groups + $group1 = [$entries[$seed1]]; + $group2 = [$entries[$seed2]]; + $mbr1 = $isLeaf ? $entries[$seed1]['mbr'] : $index['nodes'][$entries[$seed1]]['mbr']; + $mbr2 = $isLeaf ? $entries[$seed2]['mbr'] : $index['nodes'][$entries[$seed2]]['mbr']; + + // Distribute remaining entries + $minEntries = max(1, (int)($index['nodeSize'] * 0.4)); // Minimum 40% fill + + for ($i = 0; $i < count($entries); $i++) { + if ($i == $seed1 || $i == $seed2) continue; + + $entry = $entries[$i]; + $entryMBR = $isLeaf ? $entry['mbr'] : $index['nodes'][$entry]['mbr']; + + if ($entryMBR === null) { + // Assign to smaller group + if (count($group1) <= count($group2)) { + $group1[] = $entry; + } else { + $group2[] = $entry; + } + continue; + } + + // Check if one group needs all remaining entries + $remaining = count($entries) - $i - 1; + if (count($group1) + $remaining <= $minEntries) { + $group1[] = $entry; + $mbr1 = $mbr1 === null ? $entryMBR : $this->mbrUnion($mbr1, $entryMBR); + continue; + } + if (count($group2) + $remaining <= $minEntries) { + $group2[] = $entry; + $mbr2 = $mbr2 === null ? $entryMBR : $this->mbrUnion($mbr2, $entryMBR); + continue; + } + + // Choose group with least enlargement + $enlargement1 = $mbr1 === null ? 0 : $this->mbrEnlargement($mbr1, $entryMBR); + $enlargement2 = $mbr2 === null ? 0 : $this->mbrEnlargement($mbr2, $entryMBR); + + if ($enlargement1 < $enlargement2 || + ($enlargement1 == $enlargement2 && count($group1) <= count($group2))) { + $group1[] = $entry; + $mbr1 = $mbr1 === null ? $entryMBR : $this->mbrUnion($mbr1, $entryMBR); + } else { + $group2[] = $entry; + $mbr2 = $mbr2 === null ? $entryMBR : $this->mbrUnion($mbr2, $entryMBR); + } + } + + // Create new node for group2 + $newNodeId = (string)$index['nextNodeId']++; + + // Get current parent (will be used for both nodes after split) + $currentParent = $index['nodes'][$nodeId]['parent'] ?? null; + + if ($isLeaf) { + // Update original node with group1 + $index['nodes'][$nodeId]['entries'] = $group1; + $index['nodes'][$nodeId]['mbr'] = $mbr1; + + // Create new leaf node with group2 (same parent as original) + $index['nodes'][$newNodeId] = [ + 'isLeaf' => true, + 'mbr' => $mbr2, + 'entries' => $group2, + 'parent' => $currentParent + ]; + } else { + // Update original node with group1 + $index['nodes'][$nodeId]['children'] = $group1; + $index['nodes'][$nodeId]['mbr'] = $mbr1; + + // Create new internal node with group2 (same parent as original) + $index['nodes'][$newNodeId] = [ + 'isLeaf' => false, + 'mbr' => $mbr2, + 'children' => $group2, + 'parent' => $currentParent + ]; + + // Update parent pointers of children in group2 + foreach ($group2 as $childId) { + $index['nodes'][$childId]['parent'] = $newNodeId; + } + + // Ensure children in group1 point to original node + foreach ($group1 as $childId) { + $index['nodes'][$childId]['parent'] = $nodeId; + } + } + + // Handle root split + if ($nodeId == (string)$index['rootId']) { + $newRootId = (string)$index['nextNodeId']++; + $index['nodes'][$newRootId] = [ + 'isLeaf' => false, + 'mbr' => $mbr1 !== null && $mbr2 !== null ? $this->mbrUnion($mbr1, $mbr2) : ($mbr1 ?? $mbr2), + 'children' => [$nodeId, $newNodeId], + 'parent' => null + ]; + $index['rootId'] = (int)$newRootId; + + // Update parent pointers of the two children + $index['nodes'][$nodeId]['parent'] = $newRootId; + $index['nodes'][$newNodeId]['parent'] = $newRootId; + } else { + // Use parent pointer directly (O(1) instead of O(n) lookup) + $parentId = $currentParent; + if ($parentId !== null) { + $index['nodes'][$parentId]['children'][] = $newNodeId; + // Check if parent needs split + if (count($index['nodes'][$parentId]['children']) > $index['nodeSize']) { + $this->rtreeSplitNode($index, $parentId); + } + } + } + } + + /** + * Find parent node of a given node + * O(1) lookup using parent pointer + */ + private function rtreeFindParent(array &$index, string $nodeId): ?string { + return $index['nodes'][$nodeId]['parent'] ?? null; + } + + /** + * Adjust MBRs up the tree after insert/delete + * Uses parent pointers for O(1) traversal + */ + private function rtreeAdjustTree(array &$index, string $nodeId): void { + $parentId = $index['nodes'][$nodeId]['parent'] ?? null; + + while ($parentId !== null) { + $parent = &$index['nodes'][$parentId]; + $newMBR = null; + + foreach ($parent['children'] as $childId) { + $childMBR = $index['nodes'][$childId]['mbr']; + if ($childMBR !== null) { + $newMBR = $newMBR === null ? $childMBR : $this->mbrUnion($newMBR, $childMBR); + } + } + + $parent['mbr'] = $newMBR; + $parentId = $parent['parent'] ?? null; + } + } + + /** + * Delete an entry from R-tree + * @param string $dbname Database name + * @param string $field Field name + * @param int $key Record key to delete + * @param int|null $shardId Shard ID + * @return bool Success + */ + private function rtreeDelete(string $dbname, string $field, int $key, ?int $shardId = null): bool { + $index = $this->readSpatialIndex($dbname, $field, $shardId); + + if ($index === null) { + return false; + } + + // Find and remove the entry + $found = false; + foreach ($index['nodes'] as $nodeId => &$node) { + if (!$node['isLeaf']) continue; + + foreach ($node['entries'] as $i => $entry) { + if ($entry['key'] === $key) { + array_splice($node['entries'], $i, 1); + $found = true; + + // Recalculate node MBR + if (empty($node['entries'])) { + $node['mbr'] = null; + } else { + $node['mbr'] = null; + foreach ($node['entries'] as $e) { + $node['mbr'] = $node['mbr'] === null ? $e['mbr'] : $this->mbrUnion($node['mbr'], $e['mbr']); + } + } + + // Adjust tree + $this->rtreeAdjustTree($index, $nodeId); + break 2; + } + } + } + + if (!$found) { + return false; + } + + // Condense tree (remove empty nodes) + $this->rtreeCondenseTree($index); + + // Mark dirty for batch write (instead of immediate write) + $this->markSpatialIndexDirty($dbname, $field, $index, $shardId); + return true; + } + + /** + * Condense tree by removing empty nodes + * Uses parent pointers for O(1) traversal + */ + private function rtreeCondenseTree(array &$index): void { + // Remove empty leaf nodes (except root) + $nodesToRemove = []; + + foreach ($index['nodes'] as $nodeId => $node) { + if ($nodeId == (string)$index['rootId']) continue; + + if ($node['isLeaf'] && empty($node['entries'])) { + $nodesToRemove[] = $nodeId; + } elseif (!$node['isLeaf'] && empty($node['children'])) { + $nodesToRemove[] = $nodeId; + } + } + + foreach ($nodesToRemove as $nodeId) { + // Use parent pointer directly (O(1)) + $parentId = $index['nodes'][$nodeId]['parent'] ?? null; + if ($parentId !== null) { + $children = &$index['nodes'][$parentId]['children']; + $children = array_values(array_filter($children, fn($c) => $c != $nodeId)); + } + + unset($index['nodes'][$nodeId]); + } + + // If root has only one child, make that child the new root + $root = $index['nodes'][(string)$index['rootId']]; + if (!$root['isLeaf'] && count($root['children'] ?? []) === 1) { + $newRootId = $root['children'][0]; + unset($index['nodes'][(string)$index['rootId']]); + $index['rootId'] = (int)$newRootId; + // Set new root's parent to null + $index['nodes'][$newRootId]['parent'] = null; + } + } + + /** + * Search R-tree for entries overlapping with given MBR + * @param string $dbname Database name + * @param string $field Field name + * @param array $searchMBR Search MBR [minLon, minLat, maxLon, maxLat] + * @param int|null $shardId Shard ID + * @return array Array of matching entry keys + */ + private function rtreeSearchMBR(string $dbname, string $field, array $searchMBR, ?int $shardId = null): array { + $index = $this->readSpatialIndex($dbname, $field, $shardId); + + if ($index === null) { + return []; + } + + $results = []; + $this->rtreeSearchNode($index, (string)$index['rootId'], $searchMBR, $results); + + return $results; + } + + /** + * Recursive search helper + */ + private function rtreeSearchNode(array &$index, string $nodeId, array $searchMBR, array &$results): void { + $node = $index['nodes'][$nodeId]; + + // Skip if node MBR doesn't overlap search MBR + if ($node['mbr'] !== null && !$this->mbrOverlaps($node['mbr'], $searchMBR)) { + return; + } + + if ($node['isLeaf']) { + // Check each entry + foreach ($node['entries'] as $entry) { + if ($this->mbrOverlaps($entry['mbr'], $searchMBR)) { + $results[] = $entry['key']; + } + } + } else { + // Search children + foreach ($node['children'] as $childId) { + $this->rtreeSearchNode($index, (string)$childId, $searchMBR, $results); + } + } + } + + /** + * Get all entries from spatial index + * @param string $dbname Database name + * @param string $field Field name + * @param int|null $shardId Shard ID + * @return array Array of all entries + */ + private function rtreeGetAllEntries(string $dbname, string $field, ?int $shardId = null): array { + $index = $this->readSpatialIndex($dbname, $field, $shardId); + + if ($index === null) { + return []; + } + + $entries = []; + foreach ($index['nodes'] as $node) { + if ($node['isLeaf'] && !empty($node['entries'])) { + foreach ($node['entries'] as $entry) { + $entries[] = $entry; + } + } + } + + return $entries; + } + + /** + * Check if a spatial index exists + * @param string $dbname Database name + * @param string $field Field name + * @param int|null $shardId Shard ID + * @return bool + */ + public function hasSpatialIndex(string $dbname, string $field, ?int $shardId = null): bool { + $dbname = $this->sanitizeDbName($dbname); + $path = $this->getSpatialIndexPath($dbname, $field, $shardId); + return file_exists($path); + } + + /** + * Get list of spatial indexed fields for a database + * @param string $dbname Database name + * @param int|null $shardId Shard ID + * @return array Field names + */ + private function getSpatialIndexedFields(string $dbname, ?int $shardId = null): array { + $hash = $this->hashDBName($dbname); + + if ($shardId !== null) { + $pattern = $this->dbDir . $hash . '_s' . $shardId . '.nonedb.sidx.*'; + } else { + $pattern = $this->dbDir . $hash . '.nonedb.sidx.*'; + } + + $files = glob($pattern); + $fields = []; + + foreach ($files as $file) { + // Extract field name from filename + $parts = explode('.sidx.', basename($file)); + if (count($parts) >= 2) { + $fields[] = $parts[1]; + } + } + + return $fields; + } + + /** + * Invalidate spatial index cache + */ + private function invalidateSpatialIndexCache(string $dbname, string $field, ?int $shardId = null): void { + $cacheKey = $this->getSpatialIndexCacheKey($dbname, $field, $shardId); + unset($this->spatialIndexCache[$cacheKey]); + } + + // ========================================== + // SPATIAL INDEX - PUBLIC API + // ========================================== + + /** + * Create a spatial index on a GeoJSON field + * @param string $dbname Database name + * @param string $field Field name containing GeoJSON + * @param array $options Options ['nodeSize' => 16] + * @return array ['success' => bool, 'indexed' => int, 'error' => ?string] + */ + public function createSpatialIndex(string $dbname, string $field, array $options = []): array { + $dbname = $this->sanitizeDbName($dbname); + + if (!$this->spatialIndexEnabled) { + return ['success' => false, 'indexed' => 0, 'error' => 'Spatial indexing is disabled']; + } + + // Check if index already exists + if ($this->hasSpatialIndex($dbname, $field)) { + return ['success' => false, 'indexed' => 0, 'error' => "Spatial index already exists for field '$field'"]; + } + + // Handle sharded databases + if ($this->isSharded($dbname)) { + return $this->createSpatialIndexSharded($dbname, $field, $options); + } + + // Non-sharded: flush buffer and read all records + if ($this->bufferEnabled) { + $bufferPath = $this->getBufferPath($dbname); + if ($this->hasBuffer($bufferPath)) { + $this->flushBufferToMain($dbname); + } + } + + $records = $this->find($dbname); + if (!is_array($records)) { + return ['success' => false, 'indexed' => 0, 'error' => 'Failed to read records']; + } + + // Create new index + $index = $this->createEmptyRTreeIndex($field); + if (isset($options['nodeSize'])) { + $index['nodeSize'] = (int)$options['nodeSize']; + } + + $indexed = 0; + foreach ($records as $record) { + if (!isset($record[$field]) || !is_array($record[$field])) { + continue; + } + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) { + continue; + } + + $key = $record['key']; + $mbr = $this->calculateMBR($geometry); + + $entry = [ + 'key' => $key, + 'mbr' => $mbr, + 'type' => $geometry['type'] + ]; + + // Find leaf and insert + $leafId = $this->rtreeChooseLeaf($index, $mbr); + $index['nodes'][$leafId]['entries'][] = $entry; + $index['nodes'][$leafId]['mbr'] = $index['nodes'][$leafId]['mbr'] === null + ? $mbr + : $this->mbrUnion($index['nodes'][$leafId]['mbr'], $mbr); + + // Split if needed + if (count($index['nodes'][$leafId]['entries']) > $index['nodeSize']) { + $this->rtreeSplitNode($index, $leafId); + } + + $this->rtreeAdjustTree($index, $leafId); + $indexed++; + } + + $this->writeSpatialIndex($dbname, $field, $index, null); + + return ['success' => true, 'indexed' => $indexed, 'error' => null]; + } + + /** + * Create spatial index for sharded database + */ + private function createSpatialIndexSharded(string $dbname, string $field, array $options = []): array { + $meta = $this->getCachedMeta($dbname); + if (!isset($meta['shards'])) { + return ['success' => false, 'indexed' => 0, 'error' => 'No shards found']; + } + + $totalIndexed = 0; + $globalIndex = [ + 'v' => 1, + 'field' => $field, + 'shardMBRs' => [] + ]; + + foreach ($meta['shards'] as $shard) { + $shardId = $shard['id']; + + // Flush shard buffer if buffering enabled + if ($this->bufferEnabled) { + $this->flushShardBuffer($dbname, $shardId); + } + + // Create index for this shard + $index = $this->createEmptyRTreeIndex($field); + if (isset($options['nodeSize'])) { + $index['nodeSize'] = (int)$options['nodeSize']; + } + + $records = $this->findShard($dbname, $shardId, []); + $shardMBR = null; + + foreach ($records as $record) { + if (!isset($record[$field]) || !is_array($record[$field])) { + continue; + } + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) { + continue; + } + + $key = $record['key']; + $mbr = $this->calculateMBR($geometry); + + $entry = [ + 'key' => $key, + 'mbr' => $mbr, + 'type' => $geometry['type'] + ]; + + $leafId = $this->rtreeChooseLeaf($index, $mbr); + $index['nodes'][$leafId]['entries'][] = $entry; + $index['nodes'][$leafId]['mbr'] = $index['nodes'][$leafId]['mbr'] === null + ? $mbr + : $this->mbrUnion($index['nodes'][$leafId]['mbr'], $mbr); + + if (count($index['nodes'][$leafId]['entries']) > $index['nodeSize']) { + $this->rtreeSplitNode($index, $leafId); + } + + $this->rtreeAdjustTree($index, $leafId); + + // Track shard MBR + $shardMBR = $shardMBR === null ? $mbr : $this->mbrUnion($shardMBR, $mbr); + $totalIndexed++; + } + + $this->writeSpatialIndex($dbname, $field, $index, $shardId); + + // Add to global index + if ($shardMBR !== null) { + $globalIndex['shardMBRs'][(string)$shardId] = $shardMBR; + } + } + + // Write global spatial index + $this->writeGlobalSpatialIndex($dbname, $field, $globalIndex); + + return ['success' => true, 'indexed' => $totalIndexed, 'error' => null]; + } + + /** + * Drop a spatial index + * @param string $dbname Database name + * @param string $field Field name + * @return array ['success' => bool, 'error' => ?string] + */ + public function dropSpatialIndex(string $dbname, string $field): array { + $dbname = $this->sanitizeDbName($dbname); + + if ($this->isSharded($dbname)) { + $meta = $this->getCachedMeta($dbname); + if (isset($meta['shards'])) { + foreach ($meta['shards'] as $shard) { + $path = $this->getSpatialIndexPath($dbname, $field, $shard['id']); + if (file_exists($path)) { + @unlink($path); + } + $this->invalidateSpatialIndexCache($dbname, $field, $shard['id']); + } + } + // Drop global index + $globalPath = $this->getGlobalSpatialIndexPath($dbname, $field); + if (file_exists($globalPath)) { + @unlink($globalPath); + } + unset($this->globalSpatialIndexCache['gsidx:' . $dbname . ':' . $field]); + } else { + $path = $this->getSpatialIndexPath($dbname, $field, null); + if (file_exists($path)) { + @unlink($path); + } + $this->invalidateSpatialIndexCache($dbname, $field, null); + } + + return ['success' => true, 'error' => null]; + } + + /** + * Get list of spatial indexes for a database + * @param string $dbname Database name + * @return array List of indexed field names + */ + public function getSpatialIndexes(string $dbname): array { + $dbname = $this->sanitizeDbName($dbname); + + if ($this->isSharded($dbname)) { + // Check global indexes + $hash = $this->hashDBName($dbname); + $pattern = $this->dbDir . $hash . '.nonedb.gsidx.*'; + $files = glob($pattern); + $fields = []; + + foreach ($files as $file) { + $parts = explode('.gsidx.', basename($file)); + if (count($parts) >= 2) { + $fields[] = $parts[1]; + } + } + + return $fields; + } + + return $this->getSpatialIndexedFields($dbname, null); + } + + /** + * Rebuild a spatial index + * @param string $dbname Database name + * @param string $field Field name + * @return array Result from createSpatialIndex + */ + public function rebuildSpatialIndex(string $dbname, string $field): array { + $this->dropSpatialIndex($dbname, $field); + return $this->createSpatialIndex($dbname, $field); + } + + /** + * Find records within a distance from a point + * @param string $dbname Database name + * @param string $field GeoJSON field name + * @param float $lon Center longitude + * @param float $lat Center latitude + * @param float $distanceMeters Distance in meters + * @param array $options ['includeDistance' => false] + * @return array Matching records (with _distance in meters if includeDistance=true) + */ + public function withinDistance(string $dbname, string $field, float $lon, float $lat, float $distanceMeters, array $options = []): array { + $dbname = $this->sanitizeDbName($dbname); + $includeDistance = $options['includeDistance'] ?? false; + + // Convert circle to bounding box for R-tree search + $searchMBR = $this->circleToBBox($lon, $lat, $distanceMeters); + + // Get candidate keys from spatial index + $candidateKeys = []; + if ($this->isSharded($dbname)) { + $candidateKeys = $this->withinDistanceSharded($dbname, $field, $lon, $lat, $distanceMeters, $searchMBR); + } else { + if ($this->hasSpatialIndex($dbname, $field, null)) { + $candidateKeys = $this->rtreeSearchMBR($dbname, $field, $searchMBR, null); + } + } + + // Fetch and filter records + $results = []; + + if (!empty($candidateKeys)) { + // Indexed path: fetch by keys + foreach ($candidateKeys as $key) { + $record = $this->find($dbname, ['key' => $key]); + if (empty($record)) continue; + $record = $record[0] ?? $record; + + if (!isset($record[$field])) continue; + + $geometry = $record[$field]; + $centroid = $this->getGeometryCentroid($geometry); + $distance = $this->haversineDistance($lon, $lat, $centroid[0], $centroid[1]); + + if ($distance <= $distanceMeters) { + if ($includeDistance) { + $record['_distance'] = round($distance, 2); + } + $results[] = $record; + } + } + } else { + // Fallback: scan all records + $allRecords = $this->find($dbname); + foreach ($allRecords as $record) { + if (!isset($record[$field])) continue; + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) continue; + + $centroid = $this->getGeometryCentroid($geometry); + $distance = $this->haversineDistance($lon, $lat, $centroid[0], $centroid[1]); + + if ($distance <= $distanceMeters) { + if ($includeDistance) { + $record['_distance'] = round($distance, 2); + } + $results[] = $record; + } + } + } + + // Sort by distance if included + if ($includeDistance) { + usort($results, fn($a, $b) => $a['_distance'] <=> $b['_distance']); + } + + return $results; + } + + /** + * Sharded withinDistance with shard-skip optimization + */ + private function withinDistanceSharded(string $dbname, string $field, float $lon, float $lat, float $distanceMeters, array $searchMBR): array { + $globalIndex = $this->readGlobalSpatialIndex($dbname, $field); + $candidateKeys = []; + + if ($globalIndex !== null && isset($globalIndex['shardMBRs'])) { + // Shard-skip: only search shards whose MBR overlaps search MBR + foreach ($globalIndex['shardMBRs'] as $shardId => $shardMBR) { + if ($this->mbrOverlaps($searchMBR, $shardMBR)) { + $keys = $this->rtreeSearchMBR($dbname, $field, $searchMBR, (int)$shardId); + $candidateKeys = array_merge($candidateKeys, $keys); + } + } + } else { + // No global index: search all shards + $meta = $this->getCachedMeta($dbname); + if (isset($meta['shards'])) { + foreach ($meta['shards'] as $shard) { + if ($this->hasSpatialIndex($dbname, $field, $shard['id'])) { + $keys = $this->rtreeSearchMBR($dbname, $field, $searchMBR, $shard['id']); + $candidateKeys = array_merge($candidateKeys, $keys); + } + } + } + } + + return $candidateKeys; + } + + /** + * Find records within a bounding box + * @param string $dbname Database name + * @param string $field GeoJSON field name + * @param float $minLon Minimum longitude + * @param float $minLat Minimum latitude + * @param float $maxLon Maximum longitude + * @param float $maxLat Maximum latitude + * @return array Matching records + */ + public function withinBBox(string $dbname, string $field, float $minLon, float $minLat, float $maxLon, float $maxLat): array { + $dbname = $this->sanitizeDbName($dbname); + $searchMBR = [$minLon, $minLat, $maxLon, $maxLat]; + + $candidateKeys = []; + if ($this->isSharded($dbname)) { + $candidateKeys = $this->withinBBoxSharded($dbname, $field, $searchMBR); + } else { + if ($this->hasSpatialIndex($dbname, $field, null)) { + $candidateKeys = $this->rtreeSearchMBR($dbname, $field, $searchMBR, null); + } + } + + $results = []; + + if (!empty($candidateKeys)) { + foreach ($candidateKeys as $key) { + $record = $this->find($dbname, ['key' => $key]); + if (empty($record)) continue; + $record = $record[0] ?? $record; + + if (!isset($record[$field])) continue; + + // Verify MBR overlap + $geometry = $record[$field]; + $recordMBR = $this->calculateMBR($geometry); + if ($this->mbrOverlaps($recordMBR, $searchMBR)) { + $results[] = $record; + } + } + } else { + // Fallback: scan all records + $allRecords = $this->find($dbname); + foreach ($allRecords as $record) { + if (!isset($record[$field])) continue; + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) continue; + + $recordMBR = $this->calculateMBR($geometry); + if ($this->mbrOverlaps($recordMBR, $searchMBR)) { + $results[] = $record; + } + } + } + + return $results; + } + + /** + * Sharded withinBBox + */ + private function withinBBoxSharded(string $dbname, string $field, array $searchMBR): array { + $globalIndex = $this->readGlobalSpatialIndex($dbname, $field); + $candidateKeys = []; + + if ($globalIndex !== null && isset($globalIndex['shardMBRs'])) { + foreach ($globalIndex['shardMBRs'] as $shardId => $shardMBR) { + if ($this->mbrOverlaps($searchMBR, $shardMBR)) { + $keys = $this->rtreeSearchMBR($dbname, $field, $searchMBR, (int)$shardId); + $candidateKeys = array_merge($candidateKeys, $keys); + } + } + } else { + $meta = $this->getCachedMeta($dbname); + if (isset($meta['shards'])) { + foreach ($meta['shards'] as $shard) { + if ($this->hasSpatialIndex($dbname, $field, $shard['id'])) { + $keys = $this->rtreeSearchMBR($dbname, $field, $searchMBR, $shard['id']); + $candidateKeys = array_merge($candidateKeys, $keys); + } + } + } + } + + return $candidateKeys; + } + + /** + * Find K nearest records to a point + * @param string $dbname Database name + * @param string $field GeoJSON field name + * @param float $lon Center longitude + * @param float $lat Center latitude + * @param int $k Number of nearest records + * @param array $options ['maxDistance' => null (in meters), 'includeDistance' => true] + * @return array Nearest records sorted by distance (with _distance in meters) + */ + public function nearest(string $dbname, string $field, float $lon, float $lat, int $k, array $options = []): array { + $dbname = $this->sanitizeDbName($dbname); + $maxDistance = $options['maxDistance'] ?? null; + $includeDistance = $options['includeDistance'] ?? true; + + // Adaptive expanding search with exponential growth + // Start small for dense data, expand quickly for sparse data + $results = []; + $radius = 500; // Start with 500 meters + $maxRadius = $maxDistance ?? 10000000; // Max 10000km = 10M meters (global) + $attempts = 0; + $maxAttempts = 15; // Prevents infinite loop + + while (count($results) < $k && $radius <= $maxRadius && $attempts < $maxAttempts) { + $found = $this->withinDistance($dbname, $field, $lon, $lat, $radius, ['includeDistance' => true]); + + if (count($found) >= $k) { + $results = $found; + break; + } + + // Double the radius for next attempt (exponential growth) + $radius *= 2; + $attempts++; + } + + // If we still don't have enough and no max distance, do full scan + if (count($results) < $k && $maxDistance === null && $attempts >= $maxAttempts) { + $allRecords = $this->find($dbname); + $results = []; + + foreach ($allRecords as $record) { + if (!isset($record[$field])) continue; + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) continue; + + $centroid = $this->getGeometryCentroid($geometry); + $distance = $this->haversineDistance($lon, $lat, $centroid[0], $centroid[1]); + + $record['_distance'] = round($distance, 2); + $results[] = $record; + } + } + + // Sort by distance and take k + usort($results, fn($a, $b) => $a['_distance'] <=> $b['_distance']); + $results = array_slice($results, 0, $k); + + // Remove distance if not requested + if (!$includeDistance) { + foreach ($results as &$r) { + unset($r['_distance']); + } + } + + // Clear distance cache after query to free memory + $this->clearDistanceCache(); + + return $results; + } + + /** + * Find records within a polygon + * @param string $dbname Database name + * @param string $field GeoJSON field name + * @param array $polygon GeoJSON Polygon geometry + * @return array Matching records + */ + public function withinPolygon(string $dbname, string $field, array $polygon): array { + $dbname = $this->sanitizeDbName($dbname); + + // Validate polygon + $validation = $this->validateGeoJSON($polygon); + if (!$validation['valid'] || $validation['type'] !== 'Polygon') { + return []; + } + + $polygonCoords = $polygon['coordinates']; + $polygonMBR = $this->calculateMBR($polygon); + + // Get candidates from spatial index + $candidateKeys = []; + if ($this->isSharded($dbname)) { + $candidateKeys = $this->withinPolygonSharded($dbname, $field, $polygonMBR); + } else { + if ($this->hasSpatialIndex($dbname, $field, null)) { + $candidateKeys = $this->rtreeSearchMBR($dbname, $field, $polygonMBR, null); + } + } + + $results = []; + + if (!empty($candidateKeys)) { + foreach ($candidateKeys as $key) { + $record = $this->find($dbname, ['key' => $key]); + if (empty($record)) continue; + $record = $record[0] ?? $record; + + if (!isset($record[$field])) continue; + + $geometry = $record[$field]; + + // Check if geometry is within polygon + if ($this->geometryWithinPolygon($geometry, $polygonCoords)) { + $results[] = $record; + } + } + } else { + // Fallback: scan all records + $allRecords = $this->find($dbname); + foreach ($allRecords as $record) { + if (!isset($record[$field])) continue; + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) continue; + + if ($this->geometryWithinPolygon($geometry, $polygonCoords)) { + $results[] = $record; + } + } + } + + return $results; + } + + /** + * Sharded withinPolygon + */ + private function withinPolygonSharded(string $dbname, string $field, array $polygonMBR): array { + $globalIndex = $this->readGlobalSpatialIndex($dbname, $field); + $candidateKeys = []; + + if ($globalIndex !== null && isset($globalIndex['shardMBRs'])) { + foreach ($globalIndex['shardMBRs'] as $shardId => $shardMBR) { + if ($this->mbrOverlaps($polygonMBR, $shardMBR)) { + $keys = $this->rtreeSearchMBR($dbname, $field, $polygonMBR, (int)$shardId); + $candidateKeys = array_merge($candidateKeys, $keys); + } + } + } else { + $meta = $this->getCachedMeta($dbname); + if (isset($meta['shards'])) { + foreach ($meta['shards'] as $shard) { + if ($this->hasSpatialIndex($dbname, $field, $shard['id'])) { + $keys = $this->rtreeSearchMBR($dbname, $field, $polygonMBR, $shard['id']); + $candidateKeys = array_merge($candidateKeys, $keys); + } + } + } + } + + return $candidateKeys; + } + + /** + * Check if a geometry is within a polygon + */ + private function geometryWithinPolygon(array $geometry, array $polygonCoords): bool { + $type = $geometry['type'] ?? ''; + + switch ($type) { + case 'Point': + $coords = $geometry['coordinates']; + return $this->pointInPolygon($coords[0], $coords[1], $polygonCoords); + + case 'LineString': + case 'MultiPoint': + // All points must be within polygon + foreach ($geometry['coordinates'] as $point) { + if (!$this->pointInPolygon($point[0], $point[1], $polygonCoords)) { + return false; + } + } + return true; + + case 'Polygon': + // All vertices of outer ring must be within polygon + foreach ($geometry['coordinates'][0] as $point) { + if (!$this->pointInPolygon($point[0], $point[1], $polygonCoords)) { + return false; + } + } + return true; + + default: + // For complex types, check centroid + $centroid = $this->getGeometryCentroid($geometry); + return $this->pointInPolygon($centroid[0], $centroid[1], $polygonCoords); + } + } + + // ========================================== + // SPATIAL INDEX - CRUD INTEGRATION + // ========================================== + + /** + * Update spatial index when a record is inserted + * @param string $dbname Database name + * @param array $record The inserted record + * @param int $key Record key + * @param int|null $shardId Shard ID + */ + private function updateSpatialIndexOnInsert(string $dbname, array $record, int $key, ?int $shardId = null): void { + if (!$this->spatialIndexEnabled) return; + + $spatialFields = $this->getSpatialIndexedFields($dbname, $shardId); + + foreach ($spatialFields as $field) { + if (!isset($record[$field]) || !is_array($record[$field])) { + continue; + } + + $geometry = $record[$field]; + $validation = $this->validateGeoJSON($geometry); + if (!$validation['valid']) { + continue; + } + + // Insert into R-tree + $this->rtreeInsert($dbname, $field, $key, $geometry, $shardId); + + // Update global spatial index for sharded databases + if ($shardId !== null) { + $this->updateGlobalSpatialMBR($dbname, $field, $shardId, $geometry); + } + } + } + + /** + * Update spatial index when a record is deleted + * @param string $dbname Database name + * @param array $record The deleted record + * @param int $key Record key + * @param int|null $shardId Shard ID + */ + private function updateSpatialIndexOnDelete(string $dbname, array $record, int $key, ?int $shardId = null): void { + if (!$this->spatialIndexEnabled) return; + + $spatialFields = $this->getSpatialIndexedFields($dbname, $shardId); + + foreach ($spatialFields as $field) { + if (!isset($record[$field])) { + continue; + } + + // Delete from R-tree + $this->rtreeDelete($dbname, $field, $key, $shardId); + } + } + + /** + * Update spatial index when a record is updated + * @param string $dbname Database name + * @param array $oldRecord The old record + * @param array $newRecord The new record + * @param int $key Record key + * @param int|null $shardId Shard ID + */ + private function updateSpatialIndexOnUpdate(string $dbname, array $oldRecord, array $newRecord, int $key, ?int $shardId = null): void { + if (!$this->spatialIndexEnabled) return; + + $spatialFields = $this->getSpatialIndexedFields($dbname, $shardId); + + foreach ($spatialFields as $field) { + $oldGeometry = $oldRecord[$field] ?? null; + $newGeometry = $newRecord[$field] ?? null; + + // Check if geometry changed + if ($oldGeometry === $newGeometry) { + continue; + } + + // Delete old entry if it existed + if ($oldGeometry !== null && is_array($oldGeometry)) { + $this->rtreeDelete($dbname, $field, $key, $shardId); + } + + // Insert new entry if it exists + if ($newGeometry !== null && is_array($newGeometry)) { + $validation = $this->validateGeoJSON($newGeometry); + if ($validation['valid']) { + $this->rtreeInsert($dbname, $field, $key, $newGeometry, $shardId); + + // Update global MBR for sharded + if ($shardId !== null) { + $this->updateGlobalSpatialMBR($dbname, $field, $shardId, $newGeometry); + } + } + } + } + } + + /** + * Update global spatial index MBR for a shard + */ + private function updateGlobalSpatialMBR(string $dbname, string $field, int $shardId, array $geometry): void { + $globalIndex = $this->readGlobalSpatialIndex($dbname, $field); + + if ($globalIndex === null) { + $globalIndex = [ + 'v' => 1, + 'field' => $field, + 'shardMBRs' => [] + ]; + } + + $geometryMBR = $this->calculateMBR($geometry); + $shardIdStr = (string)$shardId; + + if (isset($globalIndex['shardMBRs'][$shardIdStr])) { + // Expand existing MBR + $globalIndex['shardMBRs'][$shardIdStr] = $this->mbrUnion( + $globalIndex['shardMBRs'][$shardIdStr], + $geometryMBR + ); + } else { + // New shard MBR + $globalIndex['shardMBRs'][$shardIdStr] = $geometryMBR; + } + + $this->writeGlobalSpatialIndex($dbname, $field, $globalIndex); + } + + // ========================================== + // WRITE BUFFER PUBLIC API + // ========================================== + + /** + * Manually flush buffer for a database + * @param string $dbname + * @return array ['success' => bool, 'flushed' => int, 'error' => string|null] + */ + public function flush($dbname){ + $dbname = $this->sanitizeDbName($dbname); + + if($this->isSharded($dbname)){ + $result = $this->flushAllShardBuffers($dbname); + return ['success' => true, 'flushed' => $result['flushed'], 'error' => null]; + } else { + return $this->flushBufferToMain($dbname); + } + } + + /** + * Flush all buffers for all known databases + * Called automatically on shutdown if bufferAutoFlushOnShutdown is true + * @return array ['databases' => int, 'flushed' => int] + */ + public function flushAllBuffers(){ + $dbDir = $this->dbDir; + $totalFlushed = 0; + $dbCount = 0; + + // Find all buffer files + $bufferFiles = glob($dbDir . '*.buffer'); + if($bufferFiles === false){ + $bufferFiles = []; + } + + // Track which databases we've processed + $processedDbs = []; + + foreach($bufferFiles as $bufferFile){ + $basename = basename($bufferFile); + + // Extract database name from buffer file name + // Format: hash-dbname.nonedb.buffer or hash-dbname_s0.nonedb.buffer + if(preg_match('/^[a-f0-9]+-(.+?)(?:_s\d+)?\.nonedb\.buffer$/', $basename, $matches)){ + $dbname = $matches[1]; + + // Avoid processing same DB multiple times + if(isset($processedDbs[$dbname])){ + continue; + } + $processedDbs[$dbname] = true; + + // Check if sharded or non-sharded + if($this->isSharded($dbname)){ + $result = $this->flushAllShardBuffers($dbname); + $totalFlushed += $result['flushed']; + } else { + $result = $this->flushBufferToMain($dbname); + if($result['success']){ + $totalFlushed += $result['flushed']; + } + } + $dbCount++; + } + } + + return ['databases' => $dbCount, 'flushed' => $totalFlushed]; + } + + /** + * Get buffer information for a database + * @param string $dbname + * @return array Buffer statistics + */ + public function getBufferInfo($dbname){ + $dbname = $this->sanitizeDbName($dbname); + + $info = [ + 'enabled' => $this->bufferEnabled, + 'sizeLimit' => $this->bufferSizeLimit, + 'countLimit' => $this->bufferCountLimit, + 'flushInterval' => $this->bufferFlushInterval, + 'buffers' => [] + ]; + + if($this->isSharded($dbname)){ + $meta = $this->getCachedMeta($dbname); + if($meta !== null && isset($meta['shards'])){ + foreach($meta['shards'] as $shard){ + $bufferPath = $this->getShardBufferPath($dbname, $shard['id']); + $info['buffers']['shard_' . $shard['id']] = [ + 'exists' => $this->hasBuffer($bufferPath), + 'size' => $this->getBufferSize($bufferPath), + 'records' => $this->hasBuffer($bufferPath) ? $this->getBufferRecordCount($bufferPath) : 0 + ]; + } + } + } else { + $bufferPath = $this->getBufferPath($dbname); + $info['buffers']['main'] = [ + 'exists' => $this->hasBuffer($bufferPath), + 'size' => $this->getBufferSize($bufferPath), + 'records' => $this->hasBuffer($bufferPath) ? $this->getBufferRecordCount($bufferPath) : 0 + ]; + } + + return $info; + } + + /** + * Enable or disable write buffering + * @param bool $enable + */ + public function enableBuffering($enable = true){ + $this->bufferEnabled = (bool)$enable; + } + + /** + * Check if buffering is enabled + * @return bool + */ + public function isBufferingEnabled(){ + return $this->bufferEnabled; + } + + /** + * Set buffer size limit (in bytes) + * @param int $bytes + */ + public function setBufferSizeLimit($bytes){ + $this->bufferSizeLimit = max(1024, (int)$bytes); // Minimum 1KB + } + + /** + * Set buffer flush interval (in seconds) + * @param int $seconds 0 to disable time-based flush + */ + public function setBufferFlushInterval($seconds){ + $this->bufferFlushInterval = max(0, (int)$seconds); + } + + /** + * Set buffer count limit + * @param int $count + */ + public function setBufferCountLimit($count){ + $this->bufferCountLimit = max(10, (int)$count); // Minimum 10 records + } + + /** + * Compact a database by removing null entries + * Works for both sharded and non-sharded databases + * @param string $dbname + * @return array + */ + public function compact($dbname){ + $dbname = $this->sanitizeDbName($dbname); + $result = array("success" => false, "freedSlots" => 0); + + // Handle non-sharded database + if(!$this->isSharded($dbname)){ + $hash = $this->hashDBName($dbname); + $fullDBPath = $this->dbDir . $hash . "-" . $dbname . ".nonedb"; + + if(!$this->cachedFileExists($fullDBPath)){ + $result['status'] = 'database_not_found'; + return $result; + } + + // Ensure JSONL format (auto-migrate v2 if needed) + $this->ensureJsonlFormat($dbname); + + $index = $this->readJsonlIndex($dbname); + if($index === null){ + $result['status'] = 'read_error'; + return $result; + } + + $freedSlots = $index['d']; // Dirty count = freed slots + $totalRecords = count($index['o']); // Active records in index + + $compactResult = $this->compactJsonl($dbname); + + $result['success'] = true; + $result['freedSlots'] = $freedSlots; + $result['totalRecords'] = $totalRecords; + $result['sharded'] = false; + return $result; + } + + // Handle sharded database + $meta = $this->getCachedMeta($dbname); + if($meta === null){ + $result['status'] = 'meta_read_error'; + return $result; + } + + $allRecords = []; + // Use meta's deletedCount for freedSlots (JSONL index 'd' may be 0 after auto-compaction) + $freedSlots = $meta['deletedCount'] ?? 0; + + // v3.0.0: Collect all non-null records from all shards (JSONL format) + foreach($meta['shards'] as $shard){ + $shardId = $shard['id']; $shardPath = $this->getShardPath($dbname, $shardId); // Ensure JSONL format (auto-migrate if needed) @@ -5497,6 +7716,10 @@ class noneDBQuery { private ?int $limitCount = null; private int $offsetCount = 0; + // Spatial query support (v3.1.0) + private array $spatialFilters = []; + private ?array $distanceConfig = null; + /** * @param noneDB $db * @param string $dbname @@ -5619,6 +7842,123 @@ private function hasAdvancedFilters(): bool { count($this->searchFilters) > 0; } + /** + * Check if WHERE filters contain any operator-based comparisons + * @return bool + */ + private function hasOperatorFilters(): bool { + foreach ($this->whereFilters as $value) { + if (is_array($value)) { + foreach (array_keys($value) as $key) { + if (is_string($key) && strpos($key, '$') === 0) { + return true; + } + } + } + } + return false; + } + + /** + * Check if a record matches WHERE filters with comparison operators + * Supports: $gt, $gte, $lt, $lte, $ne, $like, $in, $nin, $exists, $regex + * @param array $record + * @param array $filters WHERE filters to apply + * @return bool + */ + private function matchesWhereFilters(array $record, array $filters): bool { + foreach ($filters as $field => $value) { + if (!is_array($value)) { + // Simple equality: ['field' => 'value'] + if (!array_key_exists($field, $record) || $record[$field] !== $value) { + return false; + } + } else { + // Operator-based comparison: ['field' => ['$gt' => 10]] + foreach ($value as $operator => $operand) { + if (!$this->matchesOperator($record, $field, $operator, $operand)) { + return false; + } + } + } + } + return true; + } + + /** + * Apply a single comparison operator + * @param array $record + * @param string $field + * @param string $operator + * @param mixed $operand + * @return bool + */ + private function matchesOperator(array $record, string $field, string $operator, $operand): bool { + $hasField = array_key_exists($field, $record); + $fieldValue = $hasField ? $record[$field] : null; + + switch ($operator) { + case '$gt': + return $hasField && $fieldValue > $operand; + + case '$gte': + return $hasField && $fieldValue >= $operand; + + case '$lt': + return $hasField && $fieldValue < $operand; + + case '$lte': + return $hasField && $fieldValue <= $operand; + + case '$ne': + return !$hasField || $fieldValue !== $operand; + + case '$eq': + return $hasField && $fieldValue === $operand; + + case '$in': + return $hasField && is_array($operand) && in_array($fieldValue, $operand, true); + + case '$nin': + return !$hasField || !is_array($operand) || !in_array($fieldValue, $operand, true); + + case '$exists': + return $operand ? $hasField : !$hasField; + + case '$like': + if (!$hasField || is_array($fieldValue) || is_object($fieldValue)) return false; + $pattern = $operand; + if (strpos($pattern, '^') === 0 || substr($pattern, -1) === '$') { + $regex = '/' . $pattern . '/i'; + } else { + $regex = '/' . preg_quote($pattern, '/') . '/i'; + } + return preg_match($regex, (string)$fieldValue) === 1; + + case '$regex': + if (!$hasField || is_array($fieldValue) || is_object($fieldValue)) return false; + return preg_match('/' . $operand . '/i', (string)$fieldValue) === 1; + + case '$contains': + if (!$hasField) return false; + if (is_array($fieldValue)) { + return in_array($operand, $fieldValue, true); + } + if (is_string($fieldValue)) { + return strpos($fieldValue, $operand) !== false; + } + return false; + + default: + // Unknown operator - treat as nested field comparison + // e.g., ['address' => ['city' => 'Istanbul']] + if ($hasField && is_array($fieldValue) && array_key_exists($operator, $fieldValue)) { + return $fieldValue[$operator] === $operand; + } + return false; + } + } + // ========================================== // CHAINABLE METHODS (return $this) // ========================================== @@ -5847,6 +8187,280 @@ public function join(string $foreignDb, string $localKey, string $foreignKey, ?s return $this; } + // ========================================== + // SPATIAL QUERY METHODS (v3.1.0) + // ========================================== + + /** + * Filter records within a distance from a point + * @param string $field GeoJSON field name + * @param float $lon Center longitude + * @param float $lat Center latitude + * @param float $distanceMeters Distance in meters + * @return self + */ + public function withinDistance(string $field, float $lon, float $lat, float $distanceMeters): self { + $this->spatialFilters[] = [ + 'type' => 'withinDistance', + 'field' => $field, + 'lon' => $lon, + 'lat' => $lat, + 'distance' => $distanceMeters + ]; + return $this; + } + + /** + * Filter records within a bounding box + * @param string $field GeoJSON field name + * @param float $minLon Minimum longitude + * @param float $minLat Minimum latitude + * @param float $maxLon Maximum longitude + * @param float $maxLat Maximum latitude + * @return self + */ + public function withinBBox(string $field, float $minLon, float $minLat, float $maxLon, float $maxLat): self { + $this->spatialFilters[] = [ + 'type' => 'withinBBox', + 'field' => $field, + 'minLon' => $minLon, + 'minLat' => $minLat, + 'maxLon' => $maxLon, + 'maxLat' => $maxLat + ]; + return $this; + } + + /** + * Find K nearest records to a point + * Returns records with _distance in meters + * @param string $field GeoJSON field name + * @param float $lon Center longitude + * @param float $lat Center latitude + * @param int $k Number of nearest records + * @return self + */ + public function nearest(string $field, float $lon, float $lat, int $k): self { + $this->spatialFilters[] = [ + 'type' => 'nearest', + 'field' => $field, + 'lon' => $lon, + 'lat' => $lat, + 'k' => $k + ]; + return $this; + } + + /** + * Filter records within a polygon + * @param string $field GeoJSON field name + * @param array $polygon GeoJSON Polygon geometry + * @return self + */ + public function withinPolygon(string $field, array $polygon): self { + $this->spatialFilters[] = [ + 'type' => 'withinPolygon', + 'field' => $field, + 'polygon' => $polygon + ]; + return $this; + } + + /** + * Include distance in results (adds _distance field) + * @param string $field GeoJSON field name + * @param float $lon Center longitude + * @param float $lat Center latitude + * @return self + */ + public function withDistance(string $field, float $lon, float $lat): self { + $this->distanceConfig = [ + 'field' => $field, + 'lon' => $lon, + 'lat' => $lat + ]; + return $this; + } + + /** + * Check if query has spatial filters + * @return bool + */ + private function hasSpatialFilters(): bool { + return !empty($this->spatialFilters); + } + + /** + * Apply spatial filters to get candidate records + * @return array|null Records from spatial filter or null if no spatial filters + */ + private function applySpatialFilters(): ?array { + if (empty($this->spatialFilters)) { + return null; + } + + // Process each spatial filter and intersect results + $results = null; + + foreach ($this->spatialFilters as $filter) { + $filterResults = []; + + switch ($filter['type']) { + case 'withinDistance': + $filterResults = $this->db->withinDistance( + $this->dbname, + $filter['field'], + $filter['lon'], + $filter['lat'], + $filter['distance'], + ['includeDistance' => true] + ); + break; + + case 'withinBBox': + $filterResults = $this->db->withinBBox( + $this->dbname, + $filter['field'], + $filter['minLon'], + $filter['minLat'], + $filter['maxLon'], + $filter['maxLat'] + ); + break; + + case 'nearest': + $filterResults = $this->db->nearest( + $this->dbname, + $filter['field'], + $filter['lon'], + $filter['lat'], + $filter['k'], + ['includeDistance' => true] + ); + break; + + case 'withinPolygon': + $filterResults = $this->db->withinPolygon( + $this->dbname, + $filter['field'], + $filter['polygon'] + ); + break; + } + + // Intersect with previous results + if ($results === null) { + $results = $filterResults; + } else { + // Intersect by key + $resultKeys = array_column($results, 'key'); + $results = array_filter($filterResults, fn($r) => in_array($r['key'], $resultKeys)); + $results = array_values($results); + } + } + + return $results ?? []; + } + + /** + * Add distance to records if distanceConfig is set + * @param array $records + * @return array + */ + private function applyDistanceCalculation(array $records): array { + if ($this->distanceConfig === null) { + return $records; + } + + $field = $this->distanceConfig['field']; + $lon = $this->distanceConfig['lon']; + $lat = $this->distanceConfig['lat']; + + foreach ($records as &$record) { + if (!isset($record[$field]) || isset($record['_distance'])) { + continue; + } + + $geometry = $record[$field]; + $validation = $this->db->validateGeoJSON($geometry); + if (!$validation['valid']) { + continue; + } + + // Calculate distance to centroid + $centroid = $this->getRecordCentroid($geometry); + $record['_distance'] = round( + $this->db->haversineDistance($lon, $lat, $centroid[0], $centroid[1]), + 4 + ); + } + + return $records; + } + + /** + * Get centroid of a geometry (helper for query builder) + */ + private function getRecordCentroid(array $geometry): array { + $type = $geometry['type'] ?? ''; + $coords = $geometry['coordinates'] ?? []; + + switch ($type) { + case 'Point': + return [$coords[0], $coords[1]]; + case 'Polygon': + $ring = $coords[0]; + $sumLon = $sumLat = 0; + $n = count($ring) - 1; + for ($i = 0; $i < $n; $i++) { + $sumLon += $ring[$i][0]; + $sumLat += $ring[$i][1]; + } + return [$sumLon / $n, $sumLat / $n]; + default: + // Use MBR center + $mbr = $this->calculateSimpleMBR($geometry); + return [($mbr[0] + $mbr[2]) / 2, ($mbr[1] + $mbr[3]) / 2]; + } + } + + /** + * Simple MBR calculation for query builder + */ + private function calculateSimpleMBR(array $geometry): array { + $type = $geometry['type'] ?? ''; + $coords = $geometry['coordinates'] ?? []; + + switch ($type) { + case 'Point': + return [$coords[0], $coords[1], $coords[0], $coords[1]]; + case 'LineString': + case 'MultiPoint': + $minLon = $maxLon = $coords[0][0]; + $minLat = $maxLat = $coords[0][1]; + foreach ($coords as $p) { + $minLon = min($minLon, $p[0]); + $maxLon = max($maxLon, $p[0]); + $minLat = min($minLat, $p[1]); + $maxLat = max($maxLat, $p[1]); + } + return [$minLon, $minLat, $maxLon, $maxLat]; + case 'Polygon': + $ring = $coords[0]; + $minLon = $maxLon = $ring[0][0]; + $minLat = $maxLat = $ring[0][1]; + foreach ($ring as $p) { + $minLon = min($minLon, $p[0]); + $maxLon = max($maxLon, $p[0]); + $minLat = min($minLat, $p[1]); + $maxLat = max($maxLat, $p[1]); + } + return [$minLon, $minLat, $maxLon, $maxLat]; + default: + return [0, 0, 0, 0]; + } + } + // ========================================== // TERMINAL METHODS (execute and return result) // ========================================== @@ -5856,36 +8470,60 @@ public function join(string $foreignDb, string $localKey, string $foreignKey, ?s * @return array */ public function get(): array { - // 1. Base query - get all records first if we have OR conditions - if (count($this->orWhereFilters) > 0) { - // With OR conditions, we need all records first + // 0. Apply spatial filters first if present (v3.1.0) + if ($this->hasSpatialFilters()) { + $results = $this->applySpatialFilters(); + if ($results === null) { + $results = []; + } + + // Apply WHERE filters to spatial results (with operator support) + if (count($this->whereFilters) > 0) { + $results = array_filter($results, function($record) { + return $this->matchesWhereFilters($record, $this->whereFilters); + }); + $results = array_values($results); + } + + // Apply OR WHERE filters (with operator support) + if (count($this->orWhereFilters) > 0) { + $results = array_filter($results, function($record) { + foreach ($this->orWhereFilters as $orFilter) { + if ($this->matchesWhereFilters($record, $orFilter)) { + return true; + } + } + return false; + }); + $results = array_values($results); + } + + // Continue with advanced filters, joins, etc. + goto applyAdvancedFilters; + } + + // 1. Base query - get all records first if we have OR conditions or operator filters + $hasOperatorFilters = $this->hasOperatorFilters(); + + if (count($this->orWhereFilters) > 0 || $hasOperatorFilters) { + // With OR conditions or operators, we need all records first $results = $this->db->find($this->dbname, 0); if ($results === false) return []; - // Apply WHERE + OR WHERE logic + // Apply WHERE + OR WHERE logic (with operator support) $results = array_filter($results, function($record) { // Check main WHERE filters (AND logic) - $matchesWhere = true; - if (count($this->whereFilters) > 0) { - foreach ($this->whereFilters as $field => $value) { - if (!array_key_exists($field, $record) || $record[$field] !== $value) { - $matchesWhere = false; - break; - } - } + $matchesWhere = $this->matchesWhereFilters($record, $this->whereFilters); + + // If no OR filters, just return WHERE result + if (count($this->orWhereFilters) === 0) { + return $matchesWhere; } // Check OR WHERE filters $matchesOrWhere = false; foreach ($this->orWhereFilters as $orFilter) { - $orMatch = true; - foreach ($orFilter as $field => $value) { - if (!array_key_exists($field, $record) || $record[$field] !== $value) { - $orMatch = false; - break; - } - } - if ($orMatch) { + if ($this->matchesWhereFilters($record, $orFilter)) { $matchesOrWhere = true; break; } @@ -5896,12 +8534,13 @@ public function get(): array { }); $results = array_values($results); } else { - // No OR conditions, use standard WHERE + // No OR conditions and no operators, use standard WHERE (direct pass to find) $filters = count($this->whereFilters) > 0 ? $this->whereFilters : 0; $results = $this->db->find($this->dbname, $filters); if ($results === false) return []; } + applyAdvancedFilters: // 2-9. Apply all advanced filters in single pass (v3.0.0 optimization) // Replaces multiple array_filter calls with one pass for better performance if ($this->hasAdvancedFilters()) { @@ -6062,6 +8701,11 @@ public function get(): array { }, $results); } + // Apply distance calculation if withDistance() was called (v3.1.0) + if ($this->distanceConfig !== null) { + $results = $this->applyDistanceCalculation($results); + } + return $results; } diff --git a/tests/Feature/ComparisonOperatorTest.php b/tests/Feature/ComparisonOperatorTest.php new file mode 100644 index 0000000..5bac068 --- /dev/null +++ b/tests/Feature/ComparisonOperatorTest.php @@ -0,0 +1,768 @@ +noneDB->insert($this->testDbName, [ + ['name' => 'Alice', 'age' => 25, 'salary' => 50000, 'active' => true, 'tags' => ['developer', 'frontend'], 'department' => 'Engineering'], + ['name' => 'Bob', 'age' => 30, 'salary' => 75000, 'active' => true, 'tags' => ['developer', 'backend'], 'department' => 'Engineering'], + ['name' => 'Charlie', 'age' => 35, 'salary' => 100000, 'active' => false, 'tags' => ['manager'], 'department' => 'Management'], + ['name' => 'Diana', 'age' => 28, 'salary' => 60000, 'active' => true, 'tags' => ['designer'], 'department' => 'Design'], + ['name' => 'Eve', 'age' => 45, 'salary' => 150000, 'active' => true, 'tags' => ['director', 'manager'], 'department' => 'Executive'], + ['name' => 'Frank', 'age' => 22, 'salary' => 40000, 'active' => false, 'department' => 'Engineering'], // No tags + ['name' => 'Grace', 'age' => 33, 'salary' => 85000, 'active' => true, 'tags' => ['developer', 'fullstack'], 'department' => 'Engineering'], + ]); + } + + // ========== GREATER THAN ($gt) ========== + + /** + * Test $gt operator with integers + */ + public function testGreaterThan(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gt' => 30]]) + ->get(); + + $this->assertCount(3, $results); // Charlie(35), Eve(45), Grace(33) + + foreach ($results as $record) { + $this->assertGreaterThan(30, $record['age']); + } + } + + /** + * Test $gt with floats + */ + public function testGreaterThanFloat(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$gt' => 75000.50]]) + ->get(); + + $this->assertCount(3, $results); // Charlie, Eve, Grace + + foreach ($results as $record) { + $this->assertGreaterThan(75000.50, $record['salary']); + } + } + + /** + * Test $gt returns empty when no matches + */ + public function testGreaterThanNoMatch(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gt' => 100]]) + ->get(); + + $this->assertCount(0, $results); + } + + // ========== GREATER THAN OR EQUAL ($gte) ========== + + /** + * Test $gte operator + */ + public function testGreaterThanOrEqual(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gte' => 35]]) + ->get(); + + $this->assertCount(2, $results); // Charlie(35), Eve(45) + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(35, $record['age']); + } + } + + /** + * Test $gte at exact boundary + */ + public function testGreaterThanOrEqualBoundary(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$gte' => 50000]]) + ->get(); + + $this->assertCount(6, $results); // All except Frank (40000) + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(50000, $record['salary']); + } + } + + // ========== LESS THAN ($lt) ========== + + /** + * Test $lt operator + */ + public function testLessThan(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$lt' => 28]]) + ->get(); + + $this->assertCount(2, $results); // Alice(25), Frank(22) + + foreach ($results as $record) { + $this->assertLessThan(28, $record['age']); + } + } + + /** + * Test $lt with salary + */ + public function testLessThanSalary(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$lt' => 60000]]) + ->get(); + + $this->assertCount(2, $results); // Alice(50000), Frank(40000) + + foreach ($results as $record) { + $this->assertLessThan(60000, $record['salary']); + } + } + + // ========== LESS THAN OR EQUAL ($lte) ========== + + /** + * Test $lte operator + */ + public function testLessThanOrEqual(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$lte' => 25]]) + ->get(); + + $this->assertCount(2, $results); // Alice(25), Frank(22) + + foreach ($results as $record) { + $this->assertLessThanOrEqual(25, $record['age']); + } + } + + /** + * Test $lte at exact boundary + */ + public function testLessThanOrEqualBoundary(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$lte' => 75000]]) + ->get(); + + $this->assertCount(4, $results); // Alice(50000), Bob(75000), Diana(60000), Frank(40000) + + foreach ($results as $record) { + $this->assertLessThanOrEqual(75000, $record['salary']); + } + } + + // ========== NOT EQUAL ($ne) ========== + + /** + * Test $ne operator with string + */ + public function testNotEqual(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$ne' => 'Engineering']]) + ->get(); + + $this->assertCount(3, $results); // Charlie, Diana, Eve + + foreach ($results as $record) { + $this->assertNotEquals('Engineering', $record['department']); + } + } + + /** + * Test $ne with boolean + */ + public function testNotEqualBoolean(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['active' => ['$ne' => false]]) + ->get(); + + $this->assertCount(5, $results); // All active ones + + foreach ($results as $record) { + $this->assertTrue($record['active']); + } + } + + /** + * Test $ne with integer + */ + public function testNotEqualInteger(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$ne' => 30]]) + ->get(); + + $this->assertCount(6, $results); // All except Bob + + foreach ($results as $record) { + $this->assertNotEquals(30, $record['age']); + } + } + + // ========== EQUAL ($eq) ========== + + /** + * Test $eq operator (explicit equality) + */ + public function testEqual(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$eq' => 'Engineering']]) + ->get(); + + $this->assertCount(4, $results); // Alice, Bob, Frank, Grace + + foreach ($results as $record) { + $this->assertEquals('Engineering', $record['department']); + } + } + + // ========== IN ($in) ========== + + /** + * Test $in operator with array + */ + public function testIn(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$in' => ['Engineering', 'Design']]]) + ->get(); + + $this->assertCount(5, $results); // Alice, Bob, Diana, Frank, Grace + + foreach ($results as $record) { + $this->assertContains($record['department'], ['Engineering', 'Design']); + } + } + + /** + * Test $in with integers + */ + public function testInIntegers(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$in' => [25, 30, 35]]]) + ->get(); + + $this->assertCount(3, $results); // Alice, Bob, Charlie + + foreach ($results as $record) { + $this->assertContains($record['age'], [25, 30, 35]); + } + } + + /** + * Test $in with single value + */ + public function testInSingleValue(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$in' => ['Alice']]]) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Alice', $results[0]['name']); + } + + /** + * Test $in with empty array returns nothing + */ + public function testInEmptyArray(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$in' => []]]) + ->get(); + + $this->assertCount(0, $results); + } + + // ========== NOT IN ($nin) ========== + + /** + * Test $nin operator + */ + public function testNotIn(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$nin' => ['Engineering', 'Executive']]]) + ->get(); + + $this->assertCount(2, $results); // Charlie(Management), Diana(Design) + + foreach ($results as $record) { + $this->assertNotContains($record['department'], ['Engineering', 'Executive']); + } + } + + /** + * Test $nin with integers + */ + public function testNotInIntegers(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$nin' => [25, 45]]]) + ->get(); + + $this->assertCount(5, $results); // All except Alice and Eve + + foreach ($results as $record) { + $this->assertNotContains($record['age'], [25, 45]); + } + } + + // ========== EXISTS ($exists) ========== + + /** + * Test $exists true - field must exist + */ + public function testExistsTrue(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['tags' => ['$exists' => true]]) + ->get(); + + $this->assertCount(6, $results); // All except Frank + + foreach ($results as $record) { + $this->assertArrayHasKey('tags', $record); + } + } + + /** + * Test $exists false - field must not exist + */ + public function testExistsFalse(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['tags' => ['$exists' => false]]) + ->get(); + + $this->assertCount(1, $results); // Only Frank + + foreach ($results as $record) { + $this->assertArrayNotHasKey('tags', $record); + } + } + + /** + * Test $exists for non-existent field + */ + public function testExistsNonExistentField(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['nonexistent' => ['$exists' => false]]) + ->get(); + + $this->assertCount(7, $results); // All records + } + + // ========== LIKE ($like) ========== + + /** + * Test $like operator with contains + */ + public function testLikeContains(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$like' => 'li']]) + ->get(); + + $this->assertCount(2, $results); // Alice, Charlie + + foreach ($results as $record) { + $this->assertStringContainsStringIgnoringCase('li', $record['name']); + } + } + + /** + * Test $like with starts with (^) + */ + public function testLikeStartsWith(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$like' => '^A']]) + ->get(); + + $this->assertCount(1, $results); // Alice + $this->assertEquals('Alice', $results[0]['name']); + } + + /** + * Test $like with ends with ($) + */ + public function testLikeEndsWith(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$like' => 'e$']]) + ->get(); + + $this->assertCount(4, $results); // Alice, Charlie, Eve, Grace - all end with 'e' + // Note: case insensitive, so Grace ends with 'e' + + foreach ($results as $record) { + $this->assertMatchesRegularExpression('/e$/i', $record['name']); + } + } + + /** + * Test $like case insensitive + */ + public function testLikeCaseInsensitive(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$like' => 'ENGINEERING']]) + ->get(); + + $this->assertCount(4, $results); + } + + // ========== REGEX ($regex) ========== + + /** + * Test $regex operator + */ + public function testRegex(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$regex' => '^[A-D]']]) + ->get(); + + $this->assertCount(4, $results); // Alice, Bob, Charlie, Diana + + foreach ($results as $record) { + $this->assertMatchesRegularExpression('/^[A-D]/i', $record['name']); + } + } + + /** + * Test $regex with word pattern + */ + public function testRegexWordPattern(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$regex' => '.*ing$']]) + ->get(); + + $this->assertCount(4, $results); // Engineering + } + + // ========== CONTAINS ($contains) ========== + + /** + * Test $contains for array field + */ + public function testContainsArray(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['tags' => ['$contains' => 'developer']]) + ->get(); + + $this->assertCount(3, $results); // Alice, Bob, Grace + + foreach ($results as $record) { + $this->assertContains('developer', $record['tags']); + } + } + + /** + * Test $contains for string field + */ + public function testContainsString(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$contains' => 'sign']]) + ->get(); + + $this->assertCount(1, $results); // Design + $this->assertEquals('Design', $results[0]['department']); + } + + // ========== COMBINED OPERATORS ========== + + /** + * Test multiple operators on same field (range query) + */ + public function testRangeQuery(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gte' => 25, '$lte' => 35]]) + ->get(); + + $this->assertCount(5, $results); // Alice(25), Bob(30), Charlie(35), Diana(28), Grace(33) + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(25, $record['age']); + $this->assertLessThanOrEqual(35, $record['age']); + } + } + + /** + * Test operators across different fields + */ + public function testMultiFieldOperators(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where([ + 'age' => ['$gte' => 25], + 'salary' => ['$lt' => 80000], + 'active' => true + ]) + ->get(); + + $this->assertCount(3, $results); // Alice, Bob, Diana + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(25, $record['age']); + $this->assertLessThan(80000, $record['salary']); + $this->assertTrue($record['active']); + } + } + + /** + * Test operators with simple equality mixed + */ + public function testOperatorsWithEquality(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where([ + 'department' => 'Engineering', + 'age' => ['$gt' => 24] + ]) + ->get(); + + $this->assertCount(3, $results); // Alice(25), Bob(30), Grace(33) + + foreach ($results as $record) { + $this->assertEquals('Engineering', $record['department']); + $this->assertGreaterThan(24, $record['age']); + } + } + + // ========== EDGE CASES ========== + + /** + * Test operators with null values + */ + public function testOperatorsWithNull(): void + { + // Insert record with null value + $this->noneDB->insert($this->testDbName, [ + 'name' => 'TestNull', + 'age' => null, + 'salary' => 0, + 'active' => true, + 'department' => 'Test' + ]); + + // $gt with null should not match + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gt' => 0]]) + ->get(); + + $names = array_column($results, 'name'); + $this->assertNotContains('TestNull', $names); + + // $exists should work with null + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$exists' => true]]) + ->get(); + + $names = array_column($results, 'name'); + $this->assertContains('TestNull', $names); + } + + /** + * Test operators with zero + */ + public function testOperatorsWithZero(): void + { + // Insert record with zero values + $this->noneDB->insert($this->testDbName, [ + 'name' => 'TestZero', + 'age' => 0, + 'salary' => 0, + 'active' => false, + 'department' => 'Test' + ]); + + $results = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gte' => 0]]) + ->get(); + + $names = array_column($results, 'name'); + $this->assertContains('TestZero', $names); + + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$lte' => 0]]) + ->get(); + + $names = array_column($results, 'name'); + $this->assertContains('TestZero', $names); + } + + /** + * Test operators with negative numbers + */ + public function testOperatorsWithNegative(): void + { + // Insert record with negative value + $this->noneDB->insert($this->testDbName, [ + 'name' => 'TestNegative', + 'age' => 30, + 'salary' => -100, + 'active' => true, + 'department' => 'Test' + ]); + + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$lt' => 0]]) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('TestNegative', $results[0]['name']); + } + + /** + * Test operators with empty string + */ + public function testOperatorsWithEmptyString(): void + { + // Insert record with empty string + $this->noneDB->insert($this->testDbName, [ + 'name' => '', + 'age' => 20, + 'salary' => 30000, + 'active' => true, + 'department' => '' + ]); + + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$eq' => '']]) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('', $results[0]['name']); + + // $ne with empty string + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$ne' => '']]) + ->get(); + + $this->assertCount(7, $results); // Original 7 + } + + // ========== SORTING WITH OPERATORS ========== + + /** + * Test operators combined with sorting + */ + public function testOperatorsWithSort(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$gte' => 50000, '$lte' => 100000]]) + ->sort('salary', 'desc') + ->get(); + + $this->assertCount(5, $results); + + // Verify descending order + $prevSalary = PHP_INT_MAX; + foreach ($results as $record) { + $this->assertLessThanOrEqual($prevSalary, $record['salary']); + $prevSalary = $record['salary']; + } + } + + /** + * Test operators with limit and offset + */ + public function testOperatorsWithLimitOffset(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['active' => true]) + ->sort('age', 'asc') + ->limit(2) + ->offset(1) + ->get(); + + $this->assertCount(2, $results); + } + + // ========== CHAINABLE METHOD COMPATIBILITY ========== + + /** + * Test operators work with whereIn + */ + public function testOperatorsWithWhereIn(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$gt' => 50000]]) + ->whereIn('department', ['Engineering', 'Design']) + ->get(); + + $this->assertCount(3, $results); // Bob, Diana, Grace + + foreach ($results as $record) { + $this->assertGreaterThan(50000, $record['salary']); + $this->assertContains($record['department'], ['Engineering', 'Design']); + } + } + + /** + * Test operators work with between() + */ + public function testOperatorsWithBetween(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['department' => ['$ne' => 'Executive']]) + ->between('age', 25, 35) + ->get(); + + $this->assertCount(5, $results); + + foreach ($results as $record) { + $this->assertNotEquals('Executive', $record['department']); + $this->assertGreaterThanOrEqual(25, $record['age']); + $this->assertLessThanOrEqual(35, $record['age']); + } + } + + /** + * Test operators with orWhere + */ + public function testOperatorsWithOrWhere(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$gte' => 100000]]) + ->orWhere(['age' => ['$lt' => 25]]) + ->get(); + + $this->assertCount(3, $results); // Charlie, Eve (high salary), Frank (young) + } +} diff --git a/tests/Feature/QueryDocumentationTest.php b/tests/Feature/QueryDocumentationTest.php new file mode 100644 index 0000000..4eb9508 --- /dev/null +++ b/tests/Feature/QueryDocumentationTest.php @@ -0,0 +1,886 @@ +noneDB->insert($this->testDbName, [ + ['name' => 'John', 'email' => 'john@gmail.com', 'age' => 25, 'role' => 'admin', 'status' => 'active', 'salary' => 75000, 'department' => 'Engineering', 'created_at' => strtotime('-10 days'), 'tags' => ['developer', 'senior']], + ['name' => 'Jane', 'email' => 'jane@yahoo.com', 'age' => 30, 'role' => 'moderator', 'status' => 'active', 'salary' => 65000, 'department' => 'Design', 'created_at' => strtotime('-5 days'), 'tags' => ['designer']], + ['name' => 'Bob', 'email' => 'bob@gmail.com', 'age' => 35, 'role' => 'user', 'status' => 'inactive', 'salary' => 55000, 'department' => 'Engineering', 'created_at' => strtotime('-2 days')], + ['name' => 'Alice', 'email' => 'alice@company.com', 'age' => 28, 'role' => 'editor', 'status' => 'active', 'salary' => 60000, 'department' => 'Marketing', 'created_at' => strtotime('-1 day'), 'tags' => ['content', 'marketing']], + ['name' => 'Charlie', 'email' => 'charlie@gmail.com', 'age' => 45, 'role' => 'guest', 'status' => 'pending', 'salary' => 0, 'department' => 'Guest', 'created_at' => time()], + ['name' => 'Diana', 'email' => 'diana@test.com', 'age' => 22, 'role' => 'user', 'status' => 'active', 'salary' => 45000, 'department' => 'Support', 'created_at' => strtotime('-15 days'), 'tags' => ['support']], + ['name' => 'Eve', 'email' => 'eve@gmail.com', 'age' => 33, 'role' => 'admin', 'status' => 'active', 'salary' => 80000, 'department' => 'Engineering', 'created_at' => strtotime('-20 days'), 'tags' => ['developer', 'lead']], + ]); + } + + // ========== QUICK START EXAMPLES ========== + + /** + * Test Quick Start - Simple Query + * From: QUERY.md Quick Start section + */ + public function testQuickStartSimpleQuery(): void + { + $users = $this->noneDB->query($this->testDbName) + ->where(['status' => 'active']) + ->sort('created_at', 'desc') + ->limit(10) + ->get(); + + $this->assertGreaterThan(0, count($users)); + foreach ($users as $user) { + $this->assertEquals('active', $user['status']); + } + } + + // ========== COMPARISON OPERATORS ========== + + /** + * Test $gt operator + */ + public function testGreaterThanOperator(): void + { + $adults = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gte' => 18]]) + ->get(); + + $this->assertGreaterThan(0, count($adults)); + foreach ($adults as $user) { + $this->assertGreaterThanOrEqual(18, $user['age']); + } + } + + /** + * Test range query with $gte and $lte + */ + public function testRangeQuery(): void + { + $workingAge = $this->noneDB->query($this->testDbName) + ->where(['age' => ['$gte' => 18, '$lte' => 65]]) + ->get(); + + $this->assertGreaterThan(0, count($workingAge)); + foreach ($workingAge as $user) { + $this->assertGreaterThanOrEqual(18, $user['age']); + $this->assertLessThanOrEqual(65, $user['age']); + } + } + + /** + * Test $ne operator + */ + public function testNotEqualOperator(): void + { + $nonAdmins = $this->noneDB->query($this->testDbName) + ->where(['role' => ['$ne' => 'admin']]) + ->get(); + + foreach ($nonAdmins as $user) { + $this->assertNotEquals('admin', $user['role']); + } + } + + /** + * Test $in operator + */ + public function testInOperator(): void + { + $staff = $this->noneDB->query($this->testDbName) + ->where(['role' => ['$in' => ['admin', 'moderator', 'editor']]]) + ->get(); + + $this->assertGreaterThan(0, count($staff)); + foreach ($staff as $user) { + $this->assertContains($user['role'], ['admin', 'moderator', 'editor']); + } + } + + /** + * Test $nin operator + */ + public function testNotInOperator(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['status' => ['$nin' => ['inactive', 'pending']]]) + ->get(); + + foreach ($results as $user) { + $this->assertNotContains($user['status'], ['inactive', 'pending']); + } + } + + /** + * Test $exists operator + */ + public function testExistsOperator(): void + { + $withTags = $this->noneDB->query($this->testDbName) + ->where(['tags' => ['$exists' => true]]) + ->get(); + + foreach ($withTags as $user) { + $this->assertArrayHasKey('tags', $user); + } + + $noTags = $this->noneDB->query($this->testDbName) + ->where(['tags' => ['$exists' => false]]) + ->get(); + + foreach ($noTags as $user) { + $this->assertArrayNotHasKey('tags', $user); + } + } + + /** + * Test $like operator - contains + */ + public function testLikeContains(): void + { + $johns = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$like' => 'john']]) + ->get(); + + foreach ($johns as $user) { + $this->assertStringContainsStringIgnoringCase('john', $user['name']); + } + } + + /** + * Test $like operator - starts with + */ + public function testLikeStartsWith(): void + { + $jNames = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$like' => '^J']]) + ->get(); + + foreach ($jNames as $user) { + $this->assertMatchesRegularExpression('/^J/i', $user['name']); + } + } + + /** + * Test $like operator - ends with + */ + public function testLikeEndsWith(): void + { + $gmails = $this->noneDB->query($this->testDbName) + ->where(['email' => ['$like' => 'gmail.com$']]) + ->get(); + + foreach ($gmails as $user) { + $this->assertStringEndsWith('gmail.com', $user['email']); + } + } + + /** + * Test $regex operator + */ + public function testRegexOperator(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => ['$regex' => '^[A-D]']]) + ->get(); + + foreach ($results as $user) { + $this->assertMatchesRegularExpression('/^[A-D]/i', $user['name']); + } + } + + /** + * Test $contains operator with array + */ + public function testContainsArray(): void + { + $developers = $this->noneDB->query($this->testDbName) + ->where(['tags' => ['$contains' => 'developer']]) + ->get(); + + foreach ($developers as $user) { + $this->assertContains('developer', $user['tags']); + } + } + + /** + * Test mixed operators with simple equality + */ + public function testMixedOperatorsWithEquality(): void + { + $activeAdmins = $this->noneDB->query($this->testDbName) + ->where([ + 'role' => 'admin', + 'status' => 'active', + 'salary' => ['$gt' => 50000] + ]) + ->get(); + + foreach ($activeAdmins as $user) { + $this->assertEquals('admin', $user['role']); + $this->assertEquals('active', $user['status']); + $this->assertGreaterThan(50000, $user['salary']); + } + } + + // ========== FILTER METHODS ========== + + /** + * Test orWhere method + */ + public function testOrWhere(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['role' => 'admin']) + ->orWhere(['salary' => ['$gte' => 70000]]) + ->get(); + + $this->assertGreaterThan(0, count($results)); + foreach ($results as $user) { + $isAdmin = $user['role'] === 'admin'; + $highSalary = $user['salary'] >= 70000; + $this->assertTrue($isAdmin || $highSalary); + } + } + + /** + * Test whereIn method + */ + public function testWhereIn(): void + { + $results = $this->noneDB->query($this->testDbName) + ->whereIn('department', ['Engineering', 'Design']) + ->get(); + + foreach ($results as $user) { + $this->assertContains($user['department'], ['Engineering', 'Design']); + } + } + + /** + * Test whereNotIn method + */ + public function testWhereNotIn(): void + { + $results = $this->noneDB->query($this->testDbName) + ->whereNotIn('role', ['guest', 'user']) + ->get(); + + foreach ($results as $user) { + $this->assertNotContains($user['role'], ['guest', 'user']); + } + } + + /** + * Test whereNot method + */ + public function testWhereNot(): void + { + $results = $this->noneDB->query($this->testDbName) + ->whereNot(['role' => 'guest']) + ->get(); + + foreach ($results as $user) { + $this->assertNotEquals('guest', $user['role']); + } + } + + /** + * Test like method + */ + public function testLikeMethod(): void + { + // Contains + $results = $this->noneDB->query($this->testDbName) + ->like('email', 'gmail') + ->get(); + + foreach ($results as $user) { + $this->assertStringContainsStringIgnoringCase('gmail', $user['email']); + } + } + + /** + * Test between method + */ + public function testBetweenMethod(): void + { + $results = $this->noneDB->query($this->testDbName) + ->between('salary', 50000, 70000) + ->get(); + + foreach ($results as $user) { + $this->assertGreaterThanOrEqual(50000, $user['salary']); + $this->assertLessThanOrEqual(70000, $user['salary']); + } + } + + /** + * Test notBetween method + */ + public function testNotBetweenMethod(): void + { + $results = $this->noneDB->query($this->testDbName) + ->notBetween('age', 25, 35) + ->get(); + + foreach ($results as $user) { + $outside = $user['age'] < 25 || $user['age'] > 35; + $this->assertTrue($outside); + } + } + + /** + * Test search method + */ + public function testSearchMethod(): void + { + $results = $this->noneDB->query($this->testDbName) + ->search('gmail', ['email']) + ->get(); + + $this->assertGreaterThan(0, count($results)); + } + + // ========== SORTING & PAGINATION ========== + + /** + * Test sort method - ascending + */ + public function testSortAsc(): void + { + $results = $this->noneDB->query($this->testDbName) + ->sort('name', 'asc') + ->get(); + + $prev = ''; + foreach ($results as $user) { + $this->assertGreaterThanOrEqual($prev, $user['name']); + $prev = $user['name']; + } + } + + /** + * Test sort method - descending + */ + public function testSortDesc(): void + { + $results = $this->noneDB->query($this->testDbName) + ->sort('salary', 'desc') + ->get(); + + $prev = PHP_INT_MAX; + foreach ($results as $user) { + $this->assertLessThanOrEqual($prev, $user['salary']); + $prev = $user['salary']; + } + } + + /** + * Test limit method + */ + public function testLimit(): void + { + $results = $this->noneDB->query($this->testDbName) + ->limit(3) + ->get(); + + $this->assertCount(3, $results); + } + + /** + * Test offset method + */ + public function testOffset(): void + { + $allResults = $this->noneDB->query($this->testDbName)->get(); + $offsetResults = $this->noneDB->query($this->testDbName) + ->offset(2) + ->get(); + + $this->assertCount(count($allResults) - 2, $offsetResults); + } + + /** + * Test pagination (limit + offset) + */ + public function testPagination(): void + { + $page2 = $this->noneDB->query($this->testDbName) + ->limit(2) + ->offset(2) + ->get(); + + $this->assertLessThanOrEqual(2, count($page2)); + } + + // ========== AGGREGATION ========== + + /** + * Test count method + */ + public function testCount(): void + { + $count = $this->noneDB->query($this->testDbName)->count(); + $this->assertEquals(7, $count); + + $activeCount = $this->noneDB->query($this->testDbName) + ->where(['status' => 'active']) + ->count(); + + $this->assertGreaterThan(0, $activeCount); + } + + /** + * Test sum method + */ + public function testSum(): void + { + $totalSalary = $this->noneDB->query($this->testDbName) + ->sum('salary'); + + $this->assertIsFloat($totalSalary); + $this->assertGreaterThan(0, $totalSalary); + } + + /** + * Test avg method + */ + public function testAvg(): void + { + $avgAge = $this->noneDB->query($this->testDbName) + ->avg('age'); + + $this->assertIsFloat($avgAge); + $this->assertGreaterThan(0, $avgAge); + } + + /** + * Test min method + */ + public function testMin(): void + { + $minAge = $this->noneDB->query($this->testDbName) + ->min('age'); + + $this->assertEquals(22, $minAge); + } + + /** + * Test max method + */ + public function testMax(): void + { + $maxSalary = $this->noneDB->query($this->testDbName) + ->max('salary'); + + $this->assertEquals(80000, $maxSalary); + } + + // ========== TERMINAL METHODS ========== + + /** + * Test get method + */ + public function testGet(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['status' => 'active']) + ->get(); + + $this->assertIsArray($results); + } + + /** + * Test first method + */ + public function testFirst(): void + { + $first = $this->noneDB->query($this->testDbName) + ->where(['email' => 'john@gmail.com']) + ->first(); + + $this->assertNotNull($first); + $this->assertEquals('John', $first['name']); + } + + /** + * Test exists method + */ + public function testExists(): void + { + $hasAdmins = $this->noneDB->query($this->testDbName) + ->where(['role' => 'admin']) + ->exists(); + + $this->assertTrue($hasAdmins); + + $hasSuperadmin = $this->noneDB->query($this->testDbName) + ->where(['role' => 'superadmin']) + ->exists(); + + $this->assertFalse($hasSuperadmin); + } + + /** + * Test update method + */ + public function testUpdate(): void + { + // First insert a test record + $this->noneDB->insert($this->testDbName, [ + 'name' => 'UpdateTest', + 'status' => 'pending', + 'age' => 25 + ]); + + $result = $this->noneDB->query($this->testDbName) + ->where(['name' => 'UpdateTest']) + ->update(['status' => 'active']); + + $this->assertGreaterThan(0, $result['n']); + + // Verify + $updated = $this->noneDB->query($this->testDbName) + ->where(['name' => 'UpdateTest']) + ->first(); + + $this->assertEquals('active', $updated['status']); + } + + /** + * Test delete method + */ + public function testDelete(): void + { + // First insert a test record + $this->noneDB->insert($this->testDbName, [ + 'name' => 'DeleteTest', + 'status' => 'temp' + ]); + + $result = $this->noneDB->query($this->testDbName) + ->where(['name' => 'DeleteTest']) + ->delete(); + + $this->assertGreaterThan(0, $result['n']); + + // Verify deleted + $deleted = $this->noneDB->query($this->testDbName) + ->where(['name' => 'DeleteTest']) + ->exists(); + + $this->assertFalse($deleted); + } + + // ========== FIELD SELECTION ========== + + /** + * Test select method + */ + public function testSelect(): void + { + $results = $this->noneDB->query($this->testDbName) + ->select(['name', 'email']) + ->get(); + + foreach ($results as $user) { + $this->assertArrayHasKey('name', $user); + $this->assertArrayHasKey('email', $user); + $this->assertArrayHasKey('key', $user); // key is always included + } + } + + /** + * Test except method + */ + public function testExcept(): void + { + $results = $this->noneDB->query($this->testDbName) + ->except(['salary', 'age']) + ->get(); + + foreach ($results as $user) { + $this->assertArrayNotHasKey('salary', $user); + $this->assertArrayNotHasKey('age', $user); + $this->assertArrayHasKey('name', $user); + } + } + + // ========== COMPLEX QUERIES ========== + + /** + * Test complex query with multiple filters + */ + public function testComplexQueryMultipleFilters(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['status' => 'active']) + ->whereIn('role', ['admin', 'moderator']) + ->between('age', 25, 40) + ->sort('salary', 'desc') + ->limit(5) + ->get(); + + foreach ($results as $user) { + $this->assertEquals('active', $user['status']); + $this->assertContains($user['role'], ['admin', 'moderator']); + $this->assertGreaterThanOrEqual(25, $user['age']); + $this->assertLessThanOrEqual(40, $user['age']); + } + } + + /** + * Test operators with sorting and limiting + */ + public function testOperatorsWithSortLimit(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where([ + 'salary' => ['$gte' => 50000, '$lte' => 80000], + 'status' => 'active' + ]) + ->sort('salary', 'desc') + ->limit(3) + ->get(); + + $this->assertLessThanOrEqual(3, count($results)); + + $prev = PHP_INT_MAX; + foreach ($results as $user) { + $this->assertGreaterThanOrEqual(50000, $user['salary']); + $this->assertLessThanOrEqual(80000, $user['salary']); + $this->assertEquals('active', $user['status']); + $this->assertLessThanOrEqual($prev, $user['salary']); + $prev = $user['salary']; + } + } + + /** + * Test chainable method compatibility + */ + public function testChainableCompatibility(): void + { + // Operators + whereIn + between + $results = $this->noneDB->query($this->testDbName) + ->where(['salary' => ['$gt' => 40000]]) + ->whereIn('department', ['Engineering', 'Design', 'Marketing']) + ->between('age', 20, 40) + ->get(); + + foreach ($results as $user) { + $this->assertGreaterThan(40000, $user['salary']); + $this->assertContains($user['department'], ['Engineering', 'Design', 'Marketing']); + $this->assertGreaterThanOrEqual(20, $user['age']); + $this->assertLessThanOrEqual(40, $user['age']); + } + } + + // ========== REAL-WORLD EXAMPLES FROM DOCUMENTATION ========== + + /** + * Test E-commerce Product Search example pattern + */ + public function testEcommerceSearchPattern(): void + { + // Setup product data + $productDb = $this->testDbName . '_products'; + $this->noneDB->insert($productDb, [ + ['name' => 'Laptop', 'category' => 'electronics', 'price' => 999, 'stock' => 10, 'rating' => 4.5, 'status' => 'active'], + ['name' => 'Phone', 'category' => 'electronics', 'price' => 599, 'stock' => 25, 'rating' => 4.2, 'status' => 'active'], + ['name' => 'Headphones', 'category' => 'electronics', 'price' => 199, 'stock' => 0, 'rating' => 4.8, 'status' => 'discontinued'], + ['name' => 'Monitor', 'category' => 'computers', 'price' => 350, 'stock' => 5, 'rating' => 4.0, 'status' => 'active'], + ]); + + $products = $this->noneDB->query($productDb) + ->where([ + 'category' => ['$in' => ['electronics', 'computers']], + 'price' => ['$gte' => 100, '$lte' => 1000], + 'stock' => ['$gt' => 0], + 'rating' => ['$gte' => 4.0] + ]) + ->whereNot(['status' => 'discontinued']) + ->sort('rating', 'desc') + ->limit(10) + ->get(); + + foreach ($products as $product) { + $this->assertContains($product['category'], ['electronics', 'computers']); + $this->assertGreaterThanOrEqual(100, $product['price']); + $this->assertLessThanOrEqual(1000, $product['price']); + $this->assertGreaterThan(0, $product['stock']); + $this->assertGreaterThanOrEqual(4.0, $product['rating']); + $this->assertNotEquals('discontinued', $product['status']); + } + + // Cleanup + $this->noneDB->delete($productDb, []); + } + + /** + * Test User Authentication pattern + */ + public function testUserAuthenticationPattern(): void + { + // Setup + $authDb = $this->testDbName . '_auth'; + $passwordHash = password_hash('secret123', PASSWORD_DEFAULT); + + $this->noneDB->insert($authDb, [ + ['email' => 'valid@test.com', 'password_hash' => $passwordHash, 'status' => 'active', 'email_verified' => true], + ['email' => 'unverified@test.com', 'password_hash' => $passwordHash, 'status' => 'active', 'email_verified' => false], + ['email' => 'banned@test.com', 'password_hash' => $passwordHash, 'status' => 'banned', 'email_verified' => true], + ]); + + // Find valid user + $user = $this->noneDB->query($authDb) + ->where([ + 'email' => 'valid@test.com', + 'status' => 'active', + 'email_verified' => true + ]) + ->first(); + + $this->assertNotNull($user); + $this->assertEquals('valid@test.com', $user['email']); + + // Unverified user should not be found with email_verified filter + $unverified = $this->noneDB->query($authDb) + ->where([ + 'email' => 'unverified@test.com', + 'status' => 'active', + 'email_verified' => true + ]) + ->first(); + + $this->assertNull($unverified); + + // Cleanup + $this->noneDB->delete($authDb, []); + } + + /** + * Test Content Management pattern + */ + public function testContentManagementPattern(): void + { + $articleDb = $this->testDbName . '_articles'; + + $this->noneDB->insert($articleDb, [ + ['title' => 'PHP Tutorial', 'status' => 'published', 'published_at' => time() - 3600, 'tags' => ['php', 'tutorial', 'featured'], 'category' => 'tech'], + ['title' => 'JavaScript Guide', 'status' => 'draft', 'published_at' => null, 'tags' => ['javascript'], 'category' => 'tech'], + ['title' => 'Science News', 'status' => 'published', 'published_at' => time() - 7200, 'tags' => ['science', 'featured'], 'category' => 'science'], + ]); + + // Find published featured articles + $articles = $this->noneDB->query($articleDb) + ->where([ + 'status' => 'published', + 'published_at' => ['$lte' => time()], + 'tags' => ['$contains' => 'featured'] + ]) + ->sort('published_at', 'desc') + ->limit(10) + ->get(); + + foreach ($articles as $article) { + $this->assertEquals('published', $article['status']); + $this->assertLessThanOrEqual(time(), $article['published_at']); + $this->assertContains('featured', $article['tags']); + } + + // Cleanup + $this->noneDB->delete($articleDb, []); + } + + // ========== EDGE CASES ========== + + /** + * Test empty result handling + */ + public function testEmptyResults(): void + { + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => 'NonexistentPerson']) + ->get(); + + $this->assertIsArray($results); + $this->assertCount(0, $results); + } + + /** + * Test null value handling + */ + public function testNullValues(): void + { + $this->noneDB->insert($this->testDbName, [ + 'name' => 'NullTest', + 'email' => null, + 'age' => 30 + ]); + + $results = $this->noneDB->query($this->testDbName) + ->where(['email' => null]) + ->get(); + + $found = false; + foreach ($results as $user) { + if ($user['name'] === 'NullTest') { + $found = true; + $this->assertNull($user['email']); + } + } + $this->assertTrue($found); + } + + /** + * Test special characters in search + */ + public function testSpecialCharactersInSearch(): void + { + $this->noneDB->insert($this->testDbName, [ + 'name' => "O'Brien", + 'email' => 'obrien@test.com', + 'age' => 40 + ]); + + $results = $this->noneDB->query($this->testDbName) + ->where(['name' => "O'Brien"]) + ->get(); + + $this->assertGreaterThan(0, count($results)); + $this->assertEquals("O'Brien", $results[0]['name']); + } + + /** + * Test numeric string comparison + */ + public function testNumericStringComparison(): void + { + $this->noneDB->insert($this->testDbName, [ + 'name' => 'NumericTest', + 'code' => '100', + 'value' => 100 + ]); + + // Numeric comparison on integer field + $results = $this->noneDB->query($this->testDbName) + ->where(['value' => ['$gt' => 50]]) + ->get(); + + $found = array_filter($results, fn($r) => $r['name'] === 'NumericTest'); + $this->assertNotEmpty($found); + } +} diff --git a/tests/Feature/SpatialIndexTest.php b/tests/Feature/SpatialIndexTest.php new file mode 100644 index 0000000..82e74d8 --- /dev/null +++ b/tests/Feature/SpatialIndexTest.php @@ -0,0 +1,436 @@ +noneDB->insert($this->testDbName, [ + ['name' => 'Hagia Sophia', 'location' => ['type' => 'Point', 'coordinates' => [28.9803, 41.0086]]], + ['name' => 'Blue Mosque', 'location' => ['type' => 'Point', 'coordinates' => [28.9768, 41.0054]]], + ['name' => 'Topkapi Palace', 'location' => ['type' => 'Point', 'coordinates' => [28.9833, 41.0115]]], + ['name' => 'Grand Bazaar', 'location' => ['type' => 'Point', 'coordinates' => [28.9680, 41.0106]]], + ['name' => 'Galata Tower', 'location' => ['type' => 'Point', 'coordinates' => [28.9741, 41.0256]]] + ]); + } + + // ========== Spatial Index Management ========== + + /** + * Test creating spatial index + */ + public function testCreateSpatialIndex(): void + { + $result = $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $this->assertTrue($result['success']); + $this->assertEmpty($result['error'] ?? ''); + } + + /** + * Test creating spatial index on same field twice fails + */ + public function testCreateSpatialIndexDuplicate(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + $result = $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $this->assertFalse($result['success']); + $this->assertArrayHasKey('error', $result); + } + + /** + * Test getting spatial indexes list + */ + public function testGetSpatialIndexes(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $indexes = $this->noneDB->getSpatialIndexes($this->testDbName); + + $this->assertIsArray($indexes); + $this->assertContains('location', $indexes); + } + + /** + * Test dropping spatial index + */ + public function testDropSpatialIndex(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + $result = $this->noneDB->dropSpatialIndex($this->testDbName, 'location'); + + $this->assertTrue($result['success']); + + $indexes = $this->noneDB->getSpatialIndexes($this->testDbName); + $this->assertNotContains('location', $indexes); + } + + /** + * Test hasSpatialIndex method + */ + public function testHasSpatialIndex(): void + { + $this->assertFalse($this->noneDB->hasSpatialIndex($this->testDbName, 'location')); + + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $this->assertTrue($this->noneDB->hasSpatialIndex($this->testDbName, 'location')); + } + + // ========== WithinDistance Queries ========== + + /** + * Test withinDistance query + */ + public function testWithinDistance(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Query within 1000m (1km) of Hagia Sophia + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9803, 41.0086, 1000); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + + // Hagia Sophia should be in results (distance 0) + $names = array_column($results, 'name'); + $this->assertContains('Hagia Sophia', $names); + } + + /** + * Test withinDistance returns empty for far locations + */ + public function testWithinDistanceNoResults(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Query in completely different location (London) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', -0.1276, 51.5074, 1000); + + $this->assertIsArray($results); + $this->assertCount(0, $results); + } + + // ========== WithinBBox Queries ========== + + /** + * Test withinBBox query + */ + public function testWithinBBox(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // BBox covering Sultanahmet area + $results = $this->noneDB->withinBBox($this->testDbName, 'location', + 28.97, 41.00, // minLon, minLat + 28.99, 41.02 // maxLon, maxLat + ); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + } + + /** + * Test withinBBox returns empty for non-overlapping area + */ + public function testWithinBBoxNoResults(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // BBox far from Istanbul + $results = $this->noneDB->withinBBox($this->testDbName, 'location', + 0, 0, 1, 1 // Near Africa + ); + + $this->assertIsArray($results); + $this->assertCount(0, $results); + } + + // ========== Nearest Queries ========== + + /** + * Test nearest query + */ + public function testNearest(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Find 3 nearest to Hagia Sophia + $results = $this->noneDB->nearest($this->testDbName, 'location', 28.9803, 41.0086, 3); + + $this->assertIsArray($results); + $this->assertCount(3, $results); + + // First result should be Hagia Sophia itself (distance 0) + $this->assertEquals('Hagia Sophia', $results[0]['name']); + } + + /** + * Test nearest with distance field + */ + public function testNearestWithDistance(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->nearest($this->testDbName, 'location', 28.9803, 41.0086, 3, [ + 'includeDistance' => true + ]); + + $this->assertIsArray($results); + $this->assertArrayHasKey('_distance', $results[0]); + $this->assertEquals(0, $results[0]['_distance']); // Hagia Sophia distance to itself + } + + // ========== WithinPolygon Queries ========== + + /** + * Test withinPolygon query + */ + public function testWithinPolygon(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Polygon covering Sultanahmet + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.96, 41.00], + [28.99, 41.00], + [28.99, 41.02], + [28.96, 41.02], + [28.96, 41.00] + ]] + ]; + + $results = $this->noneDB->withinPolygon($this->testDbName, 'location', $polygon); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + } + + // ========== CRUD Integration ========== + + /** + * Test insert automatically updates spatial index + */ + public function testInsertUpdatesSpatialIndex(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Insert new location + $this->noneDB->insert($this->testDbName, [ + 'name' => 'Maiden Tower', + 'location' => ['type' => 'Point', 'coordinates' => [29.0041, 41.0211]] + ]); + + // Should find the new location (100m radius) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 29.0041, 41.0211, 100); + + $names = array_column($results, 'name'); + $this->assertContains('Maiden Tower', $names); + } + + /** + * Test delete removes from spatial index + */ + public function testDeleteRemovesFromSpatialIndex(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Find Hagia Sophia first (10m radius) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9803, 41.0086, 10); + $this->assertGreaterThan(0, count($results)); + $hagiaSophiaKey = null; + foreach ($results as $r) { + if ($r['name'] === 'Hagia Sophia') { + $hagiaSophiaKey = $r['key']; + break; + } + } + $this->assertNotNull($hagiaSophiaKey); + + // Delete it + $this->noneDB->delete($this->testDbName, ['key' => $hagiaSophiaKey]); + + // Should no longer find it + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9803, 41.0086, 10); + $names = array_column($results, 'name'); + $this->assertNotContains('Hagia Sophia', $names); + } + + /** + * Test update updates spatial index + */ + public function testUpdateUpdatesSpatialIndex(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Find Hagia Sophia (10m radius) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9803, 41.0086, 10); + $hagiaSophiaKey = null; + foreach ($results as $r) { + if ($r['name'] === 'Hagia Sophia') { + $hagiaSophiaKey = $r['key']; + break; + } + } + + // Update location + $newLocation = ['type' => 'Point', 'coordinates' => [32.8597, 39.9334]]; // Ankara + $this->noneDB->update($this->testDbName, [ + ['key' => $hagiaSophiaKey], + ['set' => ['location' => $newLocation]] + ]); + + // Should find it at new location (1000m radius) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 32.8597, 39.9334, 1000); + $names = array_column($results, 'name'); + $this->assertContains('Hagia Sophia', $names); + + // Should NOT find it at old location + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9803, 41.0086, 10); + $names = array_column($results, 'name'); + $this->assertNotContains('Hagia Sophia', $names); + } + + // ========== Query Builder Integration ========== + + /** + * Test query builder withinDistance + */ + public function testQueryBuilderWithinDistance(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.9803, 41.0086, 1000) + ->get(); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + } + + /** + * Test query builder withinBBox + */ + public function testQueryBuilderWithinBBox(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->query($this->testDbName) + ->withinBBox('location', 28.97, 41.00, 28.99, 41.02) + ->get(); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + } + + /** + * Test query builder nearest + */ + public function testQueryBuilderNearest(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->query($this->testDbName) + ->nearest('location', 28.9803, 41.0086, 2) + ->get(); + + $this->assertIsArray($results); + $this->assertCount(2, $results); + } + + /** + * Test query builder withinPolygon + */ + public function testQueryBuilderWithinPolygon(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.96, 41.00], + [28.99, 41.00], + [28.99, 41.02], + [28.96, 41.02], + [28.96, 41.00] + ]] + ]; + + $results = $this->noneDB->query($this->testDbName) + ->withinPolygon('location', $polygon) + ->get(); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + } + + /** + * Test query builder with WHERE and spatial filter + */ + public function testQueryBuilderSpatialWithWhere(): void + { + // Add categorized data + $this->noneDB->insert($this->testDbName, [ + 'name' => 'Restaurant A', + 'category' => 'restaurant', + 'location' => ['type' => 'Point', 'coordinates' => [28.9780, 41.0070]] + ]); + $this->noneDB->insert($this->testDbName, [ + 'name' => 'Hotel B', + 'category' => 'hotel', + 'location' => ['type' => 'Point', 'coordinates' => [28.9790, 41.0080]] + ]); + + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.9780, 41.0070, 1000) + ->where(['category' => 'restaurant']) + ->get(); + + $this->assertIsArray($results); + foreach ($results as $r) { + if (isset($r['category'])) { + $this->assertEquals('restaurant', $r['category']); + } + } + } + + /** + * Test query builder withDistance + */ + public function testQueryBuilderWithDistance(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.9803, 41.0086, 2000) + ->withDistance('location', 28.9803, 41.0086) + ->sort('_distance', 'asc') + ->get(); + + $this->assertIsArray($results); + $this->assertArrayHasKey('_distance', $results[0]); + + // Check results are sorted by distance + $prevDistance = -1; + foreach ($results as $r) { + $this->assertGreaterThanOrEqual($prevDistance, $r['_distance']); + $prevDistance = $r['_distance']; + } + } +} diff --git a/tests/Feature/SpatialOperatorCombinationTest.php b/tests/Feature/SpatialOperatorCombinationTest.php new file mode 100644 index 0000000..fbf1029 --- /dev/null +++ b/tests/Feature/SpatialOperatorCombinationTest.php @@ -0,0 +1,624 @@ +noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Insert realistic test data - restaurants in Istanbul + $this->noneDB->insert($this->testDbName, [ + // Sultanahmet area + [ + 'name' => 'Ottoman Kitchen', + 'category' => 'restaurant', + 'cuisine' => 'turkish', + 'price_range' => 3, // 1-5 scale + 'rating' => 4.5, + 'review_count' => 120, + 'open_now' => true, + 'delivery' => true, + 'location' => ['type' => 'Point', 'coordinates' => [28.9780, 41.0065]] + ], + [ + 'name' => 'Blue Cafe', + 'category' => 'cafe', + 'cuisine' => 'international', + 'price_range' => 2, + 'rating' => 4.2, + 'review_count' => 85, + 'open_now' => true, + 'delivery' => false, + 'location' => ['type' => 'Point', 'coordinates' => [28.9770, 41.0055]] + ], + [ + 'name' => 'Sultan Kebab', + 'category' => 'restaurant', + 'cuisine' => 'turkish', + 'price_range' => 2, + 'rating' => 4.8, + 'review_count' => 250, + 'open_now' => false, + 'delivery' => true, + 'location' => ['type' => 'Point', 'coordinates' => [28.9790, 41.0070]] + ], + // Taksim area + [ + 'name' => 'Modern Bistro', + 'category' => 'restaurant', + 'cuisine' => 'french', + 'price_range' => 4, + 'rating' => 4.6, + 'review_count' => 95, + 'open_now' => true, + 'delivery' => false, + 'location' => ['type' => 'Point', 'coordinates' => [28.9850, 41.0350]] + ], + [ + 'name' => 'Street Food Corner', + 'category' => 'fast_food', + 'cuisine' => 'turkish', + 'price_range' => 1, + 'rating' => 4.0, + 'review_count' => 500, + 'open_now' => true, + 'delivery' => true, + 'location' => ['type' => 'Point', 'coordinates' => [28.9860, 41.0360]] + ], + // Kadikoy area + [ + 'name' => 'Seafood Paradise', + 'category' => 'restaurant', + 'cuisine' => 'seafood', + 'price_range' => 4, + 'rating' => 4.7, + 'review_count' => 180, + 'open_now' => true, + 'delivery' => false, + 'location' => ['type' => 'Point', 'coordinates' => [29.0250, 40.9900]] + ], + [ + 'name' => 'Budget Bites', + 'category' => 'fast_food', + 'cuisine' => 'international', + 'price_range' => 1, + 'rating' => 3.5, + 'review_count' => 300, + 'open_now' => false, + 'delivery' => true, + 'location' => ['type' => 'Point', 'coordinates' => [29.0260, 40.9910]] + ], + // Besiktas area + [ + 'name' => 'Premium Steak House', + 'category' => 'restaurant', + 'cuisine' => 'steakhouse', + 'price_range' => 5, + 'rating' => 4.9, + 'review_count' => 75, + 'open_now' => true, + 'delivery' => false, + 'location' => ['type' => 'Point', 'coordinates' => [29.0050, 41.0430]] + ] + ]); + } + + // ========== SPATIAL + $gt/$gte COMBINATIONS ========== + + /** + * Test withinDistance + $gte rating filter + */ + public function testWithinDistanceWithGteRating(): void + { + // Find highly rated places near Sultanahmet + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) // 2000m radius + ->where(['rating' => ['$gte' => 4.5]]) + ->get(); + + $this->assertGreaterThan(0, count($results)); + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(4.5, $record['rating']); + } + } + + /** + * Test withinDistance + $gt review_count filter + */ + public function testWithinDistanceWithGtReviews(): void + { + // Find popular places (>100 reviews) near Sultanahmet + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) + ->where(['review_count' => ['$gt' => 100]]) + ->get(); + + foreach ($results as $record) { + $this->assertGreaterThan(100, $record['review_count']); + } + } + + // ========== SPATIAL + $lt/$lte COMBINATIONS ========== + + /** + * Test withinBBox + $lte price filter + */ + public function testWithinBBoxWithLtePriceRange(): void + { + // Find budget-friendly places in Sultanahmet area + $results = $this->noneDB->query($this->testDbName) + ->withinBBox('location', 28.97, 41.00, 28.99, 41.02) + ->where(['price_range' => ['$lte' => 2]]) + ->get(); + + $this->assertGreaterThan(0, count($results)); + + foreach ($results as $record) { + $this->assertLessThanOrEqual(2, $record['price_range']); + } + } + + /** + * Test nearest + $lt price filter + */ + public function testNearestWithLtPrice(): void + { + // Find nearest cheap places + $results = $this->noneDB->query($this->testDbName) + ->nearest('location', 28.978, 41.006, 10) + ->where(['price_range' => ['$lt' => 3]]) + ->withDistance('location', 28.978, 41.006) + ->get(); + + $this->assertGreaterThan(0, count($results)); + + foreach ($results as $record) { + $this->assertLessThan(3, $record['price_range']); + } + + // Verify sorted by distance + for ($i = 1; $i < count($results); $i++) { + $this->assertGreaterThanOrEqual( + $results[$i-1]['_distance'], + $results[$i]['_distance'] + ); + } + } + + // ========== SPATIAL + $ne COMBINATIONS ========== + + /** + * Test withinDistance + $ne category filter + */ + public function testWithinDistanceWithNeCategory(): void + { + // Find everything except fast food + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where(['category' => ['$ne' => 'fast_food']]) + ->get(); + + foreach ($results as $record) { + $this->assertNotEquals('fast_food', $record['category']); + } + } + + // ========== SPATIAL + $in COMBINATIONS ========== + + /** + * Test withinDistance + $in category filter + */ + public function testWithinDistanceWithInCategories(): void + { + // Find restaurants or cafes near Sultanahmet + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) + ->where(['category' => ['$in' => ['restaurant', 'cafe']]]) + ->get(); + + $this->assertGreaterThan(0, count($results)); + + foreach ($results as $record) { + $this->assertContains($record['category'], ['restaurant', 'cafe']); + } + } + + /** + * Test withinPolygon + $in cuisine filter + */ + public function testWithinPolygonWithInCuisine(): void + { + // Sultanahmet polygon + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[[28.97, 41.00], [28.99, 41.00], [28.99, 41.02], [28.97, 41.02], [28.97, 41.00]]] + ]; + + // Find Turkish or international cuisine places + $results = $this->noneDB->query($this->testDbName) + ->withinPolygon('location', $polygon) + ->where(['cuisine' => ['$in' => ['turkish', 'international']]]) + ->get(); + + foreach ($results as $record) { + $this->assertContains($record['cuisine'], ['turkish', 'international']); + } + } + + // ========== SPATIAL + $nin COMBINATIONS ========== + + /** + * Test withinDistance + $nin cuisine filter + */ + public function testWithinDistanceWithNinCuisine(): void + { + // Find places NOT serving turkish or french food + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 10000) + ->where(['cuisine' => ['$nin' => ['turkish', 'french']]]) + ->get(); + + foreach ($results as $record) { + $this->assertNotContains($record['cuisine'], ['turkish', 'french']); + } + } + + // ========== SPATIAL + RANGE ($gte + $lte) ========== + + /** + * Test withinDistance + range filter + */ + public function testWithinDistanceWithRangeFilter(): void + { + // Find moderately priced places (2-3 range) near Sultanahmet + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) + ->where(['price_range' => ['$gte' => 2, '$lte' => 3]]) + ->get(); + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(2, $record['price_range']); + $this->assertLessThanOrEqual(3, $record['price_range']); + } + } + + /** + * Test withinDistance + rating range + */ + public function testWithinDistanceWithRatingRange(): void + { + // Find good but not perfect places (4.0-4.5) + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where(['rating' => ['$gte' => 4.0, '$lt' => 4.6]]) + ->get(); + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(4.0, $record['rating']); + $this->assertLessThan(4.6, $record['rating']); + } + } + + // ========== SPATIAL + MIXED OPERATORS ========== + + /** + * Test withinDistance + mixed operators on different fields + */ + public function testWithinDistanceWithMixedOperators(): void + { + // Complex query: near, highly rated, affordable, with delivery + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 3000) + ->where([ + 'rating' => ['$gte' => 4.0], + 'price_range' => ['$lte' => 3], + 'delivery' => true + ]) + ->get(); + + foreach ($results as $record) { + $this->assertGreaterThanOrEqual(4.0, $record['rating']); + $this->assertLessThanOrEqual(3, $record['price_range']); + $this->assertTrue($record['delivery']); + } + } + + /** + * Test nearest + multiple operator filters + */ + public function testNearestWithMultipleOperators(): void + { + // Find nearest open restaurants with good ratings + $results = $this->noneDB->query($this->testDbName) + ->nearest('location', 28.978, 41.006, 10) + ->where([ + 'category' => 'restaurant', + 'open_now' => true, + 'rating' => ['$gt' => 4.0], + 'review_count' => ['$gte' => 50] + ]) + ->limit(3) + ->get(); + + foreach ($results as $record) { + $this->assertEquals('restaurant', $record['category']); + $this->assertTrue($record['open_now']); + $this->assertGreaterThan(4.0, $record['rating']); + $this->assertGreaterThanOrEqual(50, $record['review_count']); + } + } + + // ========== SPATIAL + $like COMBINATIONS ========== + + /** + * Test withinDistance + $like name filter + */ + public function testWithinDistanceWithLikeName(): void + { + // Find places with "Sultan" in name + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where(['name' => ['$like' => 'Sultan']]) + ->get(); + + foreach ($results as $record) { + $this->assertStringContainsStringIgnoringCase('Sultan', $record['name']); + } + } + + /** + * Test withinDistance + $like starts with + */ + public function testWithinDistanceWithLikeStartsWith(): void + { + // Find places starting with "B" + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 10000) + ->where(['name' => ['$like' => '^B']]) + ->get(); + + foreach ($results as $record) { + $this->assertMatchesRegularExpression('/^B/i', $record['name']); + } + } + + // ========== SPATIAL + $exists COMBINATIONS ========== + + /** + * Test withinDistance + $exists + */ + public function testWithinDistanceWithExists(): void + { + // Add a place without delivery field + $this->noneDB->insert($this->testDbName, [ + 'name' => 'Mystery Place', + 'category' => 'restaurant', + 'cuisine' => 'mystery', + 'price_range' => 3, + 'rating' => 4.0, + 'review_count' => 10, + 'open_now' => true, + // NO delivery field + 'location' => ['type' => 'Point', 'coordinates' => [28.9785, 41.0068]] + ]); + + // Find places with delivery option specified + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) + ->where(['delivery' => ['$exists' => true]]) + ->get(); + + foreach ($results as $record) { + $this->assertArrayHasKey('delivery', $record); + } + + // Find places without delivery option specified + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) + ->where(['delivery' => ['$exists' => false]]) + ->get(); + + foreach ($results as $record) { + $this->assertArrayNotHasKey('delivery', $record); + } + } + + // ========== SPATIAL + SORT + OPERATORS ========== + + /** + * Test spatial + operators + sort + */ + public function testSpatialWithOperatorsAndSort(): void + { + // Find affordable places, sorted by rating + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where(['price_range' => ['$lte' => 3]]) + ->sort('rating', 'desc') + ->get(); + + // Verify sorted by rating descending + $prevRating = PHP_INT_MAX; + foreach ($results as $record) { + $this->assertLessThanOrEqual($prevRating, $record['rating']); + $prevRating = $record['rating']; + } + } + + /** + * Test spatial + operators + distance sort + */ + public function testSpatialWithOperatorsAndDistanceSort(): void + { + // Find highly rated places, sorted by distance + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 10000) + ->where(['rating' => ['$gte' => 4.0]]) + ->withDistance('location', 28.978, 41.006) + ->sort('_distance', 'asc') + ->get(); + + // Verify sorted by distance ascending + $prevDistance = -1; + foreach ($results as $record) { + $this->assertGreaterThanOrEqual($prevDistance, $record['_distance']); + $prevDistance = $record['_distance']; + } + } + + // ========== REAL WORLD SCENARIOS ========== + + /** + * Scenario: Food delivery app - find restaurants that can deliver + */ + public function testFoodDeliveryAppScenario(): void + { + $userLon = 28.978; + $userLat = 41.006; + $maxDeliveryRadius = 3000; // meters + + // User wants: Turkish food, open now, delivery available, affordable, good rating + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', $userLon, $userLat, $maxDeliveryRadius) + ->where([ + 'cuisine' => ['$in' => ['turkish', 'international']], + 'open_now' => true, + 'delivery' => true, + 'price_range' => ['$lte' => 3], + 'rating' => ['$gte' => 4.0] + ]) + ->withDistance('location', $userLon, $userLat) + ->sort('rating', 'desc') + ->get(); + + foreach ($results as $record) { + $this->assertContains($record['cuisine'], ['turkish', 'international']); + $this->assertTrue($record['open_now']); + $this->assertTrue($record['delivery']); + $this->assertLessThanOrEqual(3, $record['price_range']); + $this->assertGreaterThanOrEqual(4.0, $record['rating']); + } + } + + /** + * Scenario: Tourist app - find top attractions in area + */ + public function testTouristAppScenario(): void + { + // Tourist at Sultanahmet looking for popular, highly reviewed restaurants + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 2000) + ->where([ + 'category' => 'restaurant', + 'review_count' => ['$gte' => 100], + 'rating' => ['$gte' => 4.5] + ]) + ->sort('review_count', 'desc') + ->limit(5) + ->get(); + + foreach ($results as $record) { + $this->assertEquals('restaurant', $record['category']); + $this->assertGreaterThanOrEqual(100, $record['review_count']); + $this->assertGreaterThanOrEqual(4.5, $record['rating']); + } + } + + /** + * Scenario: Yelp-like search - exclude certain categories + */ + public function testExcludeCategoriesScenario(): void + { + // User wants anything except fast food and cafes + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 10000) + ->where([ + 'category' => ['$nin' => ['fast_food', 'cafe']], + 'open_now' => true + ]) + ->get(); + + foreach ($results as $record) { + $this->assertNotContains($record['category'], ['fast_food', 'cafe']); + $this->assertTrue($record['open_now']); + } + } + + /** + * Scenario: Budget traveler - cheapest options nearby + */ + public function testBudgetTravelerScenario(): void + { + // Find cheapest options with acceptable ratings + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where([ + 'price_range' => ['$lte' => 2], + 'rating' => ['$gte' => 3.5] + ]) + ->sort('price_range', 'asc') + ->get(); + + foreach ($results as $record) { + $this->assertLessThanOrEqual(2, $record['price_range']); + $this->assertGreaterThanOrEqual(3.5, $record['rating']); + } + } + + // ========== EDGE CASES ========== + + /** + * Test operators with no spatial results + */ + public function testOperatorsWithNoSpatialResults(): void + { + // Search in area with no data (London) + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', -0.1276, 51.5074, 1000) + ->where(['rating' => ['$gte' => 4.0]]) + ->get(); + + $this->assertCount(0, $results); + } + + /** + * Test operators that filter all spatial results + */ + public function testOperatorsThatFilterAllResults(): void + { + // Find places with impossibly high rating + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where(['rating' => ['$gt' => 5.0]]) + ->get(); + + $this->assertCount(0, $results); + } + + /** + * Test empty operator arrays + */ + public function testEmptyOperatorArrays(): void + { + // $in with empty array should return nothing + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.978, 41.006, 5000) + ->where(['category' => ['$in' => []]]) + ->get(); + + $this->assertCount(0, $results); + } +} diff --git a/tests/Feature/SpatialRealWorldTest.php b/tests/Feature/SpatialRealWorldTest.php new file mode 100644 index 0000000..e5a3966 --- /dev/null +++ b/tests/Feature/SpatialRealWorldTest.php @@ -0,0 +1,553 @@ +noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Generate 100 random locations in Istanbul + $data = []; + for ($i = 0; $i < 100; $i++) { + $data[] = [ + 'name' => "Location $i", + 'category' => ['restaurant', 'cafe', 'hotel', 'museum'][$i % 4], + 'location' => [ + 'type' => 'Point', + 'coordinates' => [ + 28.8 + (mt_rand(0, 400) / 1000), // 28.8-29.2 + 40.9 + (mt_rand(0, 200) / 1000) // 40.9-41.1 + ] + ] + ]; + } + + $result = $this->noneDB->insert($this->testDbName, $data); + $this->assertEquals(100, $result['n']); + + // Verify spatial queries work (50km = 50000m) + $nearby = $this->noneDB->withinDistance($this->testDbName, 'location', 29.0, 41.0, 50000); + $this->assertGreaterThan(0, count($nearby)); + } + + /** + * Test bulk delete with spatial index + */ + public function testBulkDeleteWithSpatialIndex(): void + { + // Insert data with spatial index + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $data = []; + for ($i = 0; $i < 50; $i++) { + $data[] = [ + 'name' => "Place $i", + 'active' => $i < 25, // First 25 are active + 'location' => [ + 'type' => 'Point', + 'coordinates' => [28.9 + ($i * 0.001), 41.0] + ] + ]; + } + $this->noneDB->insert($this->testDbName, $data); + + // Delete inactive records + $deleteResult = $this->noneDB->delete($this->testDbName, ['active' => false]); + $this->assertEquals(25, $deleteResult['n']); + + // Verify spatial index is updated - deleted records should not appear (100km = 100000m) + $all = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9, 41.0, 100000); + $this->assertCount(25, $all); + + // All remaining should be active + foreach ($all as $record) { + $this->assertTrue($record['active']); + } + } + + /** + * Test bulk update with spatial index (location changes) + */ + public function testBulkUpdateWithSpatialIndex(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Insert in Istanbul + $data = []; + for ($i = 0; $i < 20; $i++) { + $data[] = [ + 'name' => "Business $i", + 'city' => 'istanbul', + 'location' => [ + 'type' => 'Point', + 'coordinates' => [28.9 + ($i * 0.01), 41.0] + ] + ]; + } + $this->noneDB->insert($this->testDbName, $data); + + // Move Business 10 to Ankara + $updateResult = $this->noneDB->update($this->testDbName, [ + ['name' => 'Business 10'], + ['set' => [ + 'city' => 'ankara', + 'location' => ['type' => 'Point', 'coordinates' => [32.8, 39.9]] + ]] + ]); + $this->assertEquals(1, $updateResult['n']); + + // Verify spatial index updated (50km = 50000m) + $inIstanbul = $this->noneDB->withinDistance($this->testDbName, 'location', 29.0, 41.0, 50000); + $inAnkara = $this->noneDB->withinDistance($this->testDbName, 'location', 32.8, 39.9, 50000); + + $this->assertEquals(19, count($inIstanbul)); // 19 left in Istanbul + $this->assertEquals(1, count($inAnkara)); // 1 moved to Ankara + } + + // ========== EDGE CASES ========== + + /** + * Test empty result queries + */ + public function testEmptyResultQueries(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Insert in Istanbul + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Place 1', 'location' => ['type' => 'Point', 'coordinates' => [28.9, 41.0]]] + ]); + + // Query in completely different location (London) - 10km = 10000m + $results = $this->noneDB->withinDistance($this->testDbName, 'location', -0.1, 51.5, 10000); + $this->assertCount(0, $results); + + // Nearest with no results in range + $nearest = $this->noneDB->nearest($this->testDbName, 'location', -0.1, 51.5, 5, [ + 'maxDistance' => 100000 // 100km = 100000m max + ]); + $this->assertCount(0, $nearest); + } + + /** + * Test queries on empty database + */ + public function testQueriesOnEmptyDatabase(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9, 41.0, 10000); + $this->assertCount(0, $results); + + $bbox = $this->noneDB->withinBBox($this->testDbName, 'location', 28, 40, 30, 42); + $this->assertCount(0, $bbox); + + $nearest = $this->noneDB->nearest($this->testDbName, 'location', 28.9, 41.0, 5); + $this->assertCount(0, $nearest); + } + + /** + * Test very small distances (meters) + */ + public function testVerySmallDistances(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Two points ~100 meters apart + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Point A', 'location' => ['type' => 'Point', 'coordinates' => [28.9800, 41.0000]]], + ['name' => 'Point B', 'location' => ['type' => 'Point', 'coordinates' => [28.9810, 41.0000]]] // ~80m east + ]); + + // 50m radius - should find only Point A + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9800, 41.0000, 50); + $this->assertCount(1, $results); + $this->assertEquals('Point A', $results[0]['name']); + + // 150m radius - should find both + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9800, 41.0000, 150); + $this->assertCount(2, $results); + } + + /** + * Test very large distances (global) + */ + public function testVeryLargeDistances(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Points on different continents + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Istanbul', 'location' => ['type' => 'Point', 'coordinates' => [28.9, 41.0]]], + ['name' => 'New York', 'location' => ['type' => 'Point', 'coordinates' => [-74.0, 40.7]]], + ['name' => 'Tokyo', 'location' => ['type' => 'Point', 'coordinates' => [139.7, 35.7]]] + ]); + + // 10000km should find Istanbul and NY from Istanbul (10000000m) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9, 41.0, 10000000); + $this->assertGreaterThanOrEqual(2, count($results)); + + // 20000km should find all (20000000m) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9, 41.0, 20000000); + $this->assertCount(3, $results); + } + + /** + * Test international date line - edge case + * Note: Date line crossing BBox requires special handling (not currently supported) + * This test verifies normal queries work near the date line + */ + public function testInternationalDateLine(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Points near date line (both on same side) + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Fiji', 'location' => ['type' => 'Point', 'coordinates' => [179.0, -18.0]]], + ['name' => 'Tonga', 'location' => ['type' => 'Point', 'coordinates' => [175.0, -21.0]]] + ]); + + // BBox on one side of date line + $results = $this->noneDB->withinBBox($this->testDbName, 'location', 174, -22, 180, -17); + $this->assertEquals(2, count($results)); + + // Distance query works across date line (1000km = 1000000m) + $nearby = $this->noneDB->withinDistance($this->testDbName, 'location', 179.0, -18.0, 1000000); + $this->assertGreaterThanOrEqual(1, count($nearby)); + } + + /** + * Test poles proximity + */ + public function testPolesProximity(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Point near North Pole + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Arctic Station', 'location' => ['type' => 'Point', 'coordinates' => [0, 89.0]]] + ]); + + // Query near pole (100km = 100000m) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 0, 89.5, 100000); + $this->assertCount(1, $results); + } + + // ========== COMBINED QUERIES ========== + + /** + * Test spatial + attribute filtering (real-world scenario) + */ + public function testSpatialWithAttributeFiltering(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Insert diverse data + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Cheap Restaurant', 'type' => 'restaurant', 'rating' => 3, 'price' => 'low', + 'location' => ['type' => 'Point', 'coordinates' => [28.980, 41.008]]], + ['name' => 'Fancy Restaurant', 'type' => 'restaurant', 'rating' => 5, 'price' => 'high', + 'location' => ['type' => 'Point', 'coordinates' => [28.981, 41.009]]], + ['name' => 'Budget Hotel', 'type' => 'hotel', 'rating' => 3, 'price' => 'low', + 'location' => ['type' => 'Point', 'coordinates' => [28.982, 41.007]]], + ['name' => 'Luxury Hotel', 'type' => 'hotel', 'rating' => 5, 'price' => 'high', + 'location' => ['type' => 'Point', 'coordinates' => [28.983, 41.010]]], + ['name' => 'Cafe', 'type' => 'cafe', 'rating' => 4, 'price' => 'medium', + 'location' => ['type' => 'Point', 'coordinates' => [28.979, 41.006]]] + ]); + + // Find nearby restaurants with high rating (5km = 5000m radius to include all) + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.980, 41.008, 5000) + ->where(['type' => 'restaurant', 'rating' => ['$gte' => 4]]) + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Fancy Restaurant', $results[0]['name']); + + // Find nearby budget places (5km = 5000m) + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', 28.980, 41.008, 5000) + ->where(['price' => 'low']) + ->get(); + + $this->assertCount(2, $results); + } + + /** + * Test nearest with filters (find nearest open restaurant) + */ + public function testNearestWithFilters(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Restaurant A', 'open' => false, 'location' => ['type' => 'Point', 'coordinates' => [28.980, 41.008]]], + ['name' => 'Restaurant B', 'open' => true, 'location' => ['type' => 'Point', 'coordinates' => [28.985, 41.010]]], + ['name' => 'Restaurant C', 'open' => true, 'location' => ['type' => 'Point', 'coordinates' => [28.990, 41.012]]] + ]); + + // Nearest open restaurant + $results = $this->noneDB->query($this->testDbName) + ->nearest('location', 28.980, 41.008, 10) + ->where(['open' => true]) + ->withDistance('location', 28.980, 41.008) + ->get(); + + $this->assertGreaterThan(0, count($results)); + $this->assertTrue($results[0]['open']); + // Restaurant B should be first (closest open) + $this->assertEquals('Restaurant B', $results[0]['name']); + } + + // ========== GEOMETRY TYPES ========== + + /** + * Test different geometry types together + */ + public function testMixedGeometryTypes(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'geometry'); + + $this->noneDB->insert($this->testDbName, [ + [ + 'name' => 'Point Location', + 'type' => 'poi', + 'geometry' => ['type' => 'Point', 'coordinates' => [28.980, 41.008]] + ], + [ + 'name' => 'Road Segment', + 'type' => 'road', + 'geometry' => [ + 'type' => 'LineString', + 'coordinates' => [[28.975, 41.005], [28.980, 41.008], [28.985, 41.010]] + ] + ], + [ + 'name' => 'Park Area', + 'type' => 'park', + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [[[28.970, 41.000], [28.990, 41.000], [28.990, 41.015], [28.970, 41.015], [28.970, 41.000]]] + ] + ] + ]); + + // All should be found within the area + $results = $this->noneDB->withinBBox($this->testDbName, 'geometry', 28.96, 40.99, 29.00, 41.02); + $this->assertCount(3, $results); + } + + /** + * Test polygon with hole + */ + public function testPolygonWithHole(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'boundary'); + + // Park with a lake (hole) in the middle + $this->noneDB->insert($this->testDbName, [ + 'name' => 'City Park', + 'boundary' => [ + 'type' => 'Polygon', + 'coordinates' => [ + [[28.97, 41.00], [29.03, 41.00], [29.03, 41.06], [28.97, 41.06], [28.97, 41.00]], // Outer + [[28.99, 41.02], [29.01, 41.02], [29.01, 41.04], [28.99, 41.04], [28.99, 41.02]] // Inner (hole) + ] + ] + ]); + + // Point in outer ring but outside hole should be found + $results = $this->noneDB->withinBBox($this->testDbName, 'boundary', 28.96, 40.99, 29.04, 41.07); + $this->assertCount(1, $results); + } + + // ========== CONCURRENT OPERATIONS ========== + + /** + * Test multiple spatial index creates on same field (should fail) + */ + public function testDuplicateSpatialIndexCreate(): void + { + $result1 = $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + $this->assertTrue($result1['success']); + + $result2 = $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + $this->assertFalse($result2['success']); + $this->assertArrayHasKey('error', $result2); + } + + /** + * Test drop and recreate spatial index + */ + public function testDropAndRecreateSpatialIndex(): void + { + // Create and populate + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + $this->noneDB->insert($this->testDbName, [ + ['name' => 'A', 'location' => ['type' => 'Point', 'coordinates' => [28.9, 41.0]]] + ]); + + // Verify works (1km = 1000m) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9, 41.0, 1000); + $this->assertCount(1, $results); + + // Drop + $this->noneDB->dropSpatialIndex($this->testDbName, 'location'); + + // Recreate + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Should still work (rebuild from data) + $results = $this->noneDB->withinDistance($this->testDbName, 'location', 28.9, 41.0, 1000); + $this->assertCount(1, $results); + } + + // ========== REAL-WORLD SCENARIOS ========== + + /** + * Test food delivery app scenario: find restaurants within delivery radius + */ + public function testFoodDeliveryScenario(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Restaurants with delivery radius in meters + $this->noneDB->insert($this->testDbName, [ + ['name' => 'Pizza Place', 'cuisine' => 'italian', 'delivery_radius_m' => 5000, + 'min_order' => 50, 'rating' => 4.5, 'open' => true, + 'location' => ['type' => 'Point', 'coordinates' => [28.980, 41.008]]], + ['name' => 'Burger Joint', 'cuisine' => 'american', 'delivery_radius_m' => 3000, + 'min_order' => 30, 'rating' => 4.2, 'open' => true, + 'location' => ['type' => 'Point', 'coordinates' => [28.985, 41.010]]], + ['name' => 'Sushi Bar', 'cuisine' => 'japanese', 'delivery_radius_m' => 7000, + 'min_order' => 100, 'rating' => 4.8, 'open' => false, + 'location' => ['type' => 'Point', 'coordinates' => [28.990, 41.005]]] + ]); + + // Customer location + $customerLon = 28.982; + $customerLat = 41.009; + + // Find open restaurants that can deliver to customer, sorted by rating (10km = 10000m) + $results = $this->noneDB->query($this->testDbName) + ->withinDistance('location', $customerLon, $customerLat, 10000) + ->where(['open' => true]) + ->withDistance('location', $customerLon, $customerLat) + ->sort('rating', 'desc') + ->get(); + + // Filter by delivery radius (customer must be within restaurant's delivery range, both in meters) + $canDeliver = array_filter($results, function($r) { + return $r['_distance'] <= $r['delivery_radius_m']; + }); + + $this->assertGreaterThan(0, count($canDeliver)); + foreach ($canDeliver as $restaurant) { + $this->assertTrue($restaurant['open']); + $this->assertLessThanOrEqual($restaurant['delivery_radius_m'], $restaurant['_distance']); + } + } + + /** + * Test ride-sharing app scenario: find nearest available drivers + */ + public function testRideSharingScenario(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'current_location'); + + // Drivers with various statuses + $this->noneDB->insert($this->testDbName, [ + ['driver_id' => 'D1', 'status' => 'available', 'car_type' => 'sedan', 'rating' => 4.8, + 'current_location' => ['type' => 'Point', 'coordinates' => [28.980, 41.008]]], + ['driver_id' => 'D2', 'status' => 'busy', 'car_type' => 'suv', 'rating' => 4.5, + 'current_location' => ['type' => 'Point', 'coordinates' => [28.981, 41.009]]], + ['driver_id' => 'D3', 'status' => 'available', 'car_type' => 'sedan', 'rating' => 4.9, + 'current_location' => ['type' => 'Point', 'coordinates' => [28.985, 41.010]]], + ['driver_id' => 'D4', 'status' => 'available', 'car_type' => 'suv', 'rating' => 4.7, + 'current_location' => ['type' => 'Point', 'coordinates' => [28.990, 41.012]]] + ]); + + // Passenger location + $passengerLon = 28.982; + $passengerLat = 41.009; + + // Find nearest available drivers + $nearestDrivers = $this->noneDB->query($this->testDbName) + ->nearest('current_location', $passengerLon, $passengerLat, 10) + ->where(['status' => 'available']) + ->withDistance('current_location', $passengerLon, $passengerLat) + ->limit(3) + ->get(); + + $this->assertGreaterThan(0, count($nearestDrivers)); + foreach ($nearestDrivers as $driver) { + $this->assertEquals('available', $driver['status']); + } + + // Distances should be in ascending order + for ($i = 1; $i < count($nearestDrivers); $i++) { + $this->assertGreaterThanOrEqual( + $nearestDrivers[$i-1]['_distance'], + $nearestDrivers[$i]['_distance'] + ); + } + } + + /** + * Test real estate app scenario: find properties in area with price filter + */ + public function testRealEstateScenario(): void + { + $this->noneDB->createSpatialIndex($this->testDbName, 'location'); + + // Properties + $this->noneDB->insert($this->testDbName, [ + ['title' => 'Luxury Apartment', 'price' => 500000, 'bedrooms' => 3, 'type' => 'apartment', + 'location' => ['type' => 'Point', 'coordinates' => [28.980, 41.008]]], + ['title' => 'Budget Studio', 'price' => 150000, 'bedrooms' => 1, 'type' => 'apartment', + 'location' => ['type' => 'Point', 'coordinates' => [28.985, 41.010]]], + ['title' => 'Family House', 'price' => 800000, 'bedrooms' => 4, 'type' => 'house', + 'location' => ['type' => 'Point', 'coordinates' => [28.990, 41.012]]], + ['title' => 'Modern Condo', 'price' => 350000, 'bedrooms' => 2, 'type' => 'apartment', + 'location' => ['type' => 'Point', 'coordinates' => [28.975, 41.005]]] + ]); + + // Search area polygon (neighborhood boundary) + $searchArea = [ + 'type' => 'Polygon', + 'coordinates' => [[[28.97, 41.00], [29.00, 41.00], [29.00, 41.02], [28.97, 41.02], [28.97, 41.00]]] + ]; + + // Find apartments under 400k in the area + $results = $this->noneDB->query($this->testDbName) + ->withinPolygon('location', $searchArea) + ->where([ + 'type' => 'apartment', + 'price' => ['$lte' => 400000] + ]) + ->sort('price', 'asc') + ->get(); + + $this->assertGreaterThan(0, count($results)); + foreach ($results as $property) { + $this->assertEquals('apartment', $property['type']); + $this->assertLessThanOrEqual(400000, $property['price']); + } + } +} diff --git a/tests/Unit/GeoJSONValidationTest.php b/tests/Unit/GeoJSONValidationTest.php new file mode 100644 index 0000000..f3cd429 --- /dev/null +++ b/tests/Unit/GeoJSONValidationTest.php @@ -0,0 +1,350 @@ + 'Point', + 'coordinates' => [28.9803, 41.0086] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + $this->assertNull($result['error'] ?? null); + } + + /** + * Test Point with invalid coordinates (not array) + */ + public function testPointInvalidCoordinatesType(): void + { + $geometry = [ + 'type' => 'Point', + 'coordinates' => 'invalid' + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('array', $result['error']); + } + + /** + * Test Point with missing coordinates + */ + public function testPointMissingCoordinates(): void + { + $geometry = [ + 'type' => 'Point' + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + } + + /** + * Test Point with wrong number of coordinates + */ + public function testPointWrongCoordinateCount(): void + { + $geometry = [ + 'type' => 'Point', + 'coordinates' => [28.9803] // Only one coordinate + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + } + + /** + * Test Point with invalid longitude (out of range) + */ + public function testPointInvalidLongitude(): void + { + $geometry = [ + 'type' => 'Point', + 'coordinates' => [200, 41.0086] // Longitude > 180 + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('longitude', strtolower($result['error'])); + } + + /** + * Test Point with invalid latitude (out of range) + */ + public function testPointInvalidLatitude(): void + { + $geometry = [ + 'type' => 'Point', + 'coordinates' => [28.9803, 100] // Latitude > 90 + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('latitude', strtolower($result['error'])); + } + + /** + * Test valid LineString geometry + */ + public function testValidLineString(): void + { + $geometry = [ + 'type' => 'LineString', + 'coordinates' => [ + [28.9803, 41.0086], + [29.0097, 41.0422] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test LineString with only one point (invalid) + */ + public function testLineStringTooFewPoints(): void + { + $geometry = [ + 'type' => 'LineString', + 'coordinates' => [ + [28.9803, 41.0086] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('2', $result['error']); + } + + /** + * Test valid Polygon geometry + */ + public function testValidPolygon(): void + { + $geometry = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], + [28.99, 41.00], + [28.99, 41.02], + [28.97, 41.02], + [28.97, 41.00] // Closed ring + ]] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test Polygon with unclosed ring (invalid) + */ + public function testPolygonUnclosedRing(): void + { + $geometry = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], + [28.99, 41.00], + [28.99, 41.02], + [28.97, 41.02] + // Missing closing point + ]] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('closed', strtolower($result['error'])); + } + + /** + * Test Polygon with too few points + */ + public function testPolygonTooFewPoints(): void + { + $geometry = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.97, 41.00], + [28.99, 41.00], + [28.97, 41.00] // Only 3 points (triangle needs 4 minimum) + ]] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + } + + /** + * Test Polygon with hole + */ + public function testPolygonWithHole(): void + { + $geometry = [ + 'type' => 'Polygon', + 'coordinates' => [ + // Outer ring + [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], + // Inner ring (hole) + [[2, 2], [8, 2], [8, 8], [2, 8], [2, 2]] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test valid MultiPoint geometry + */ + public function testValidMultiPoint(): void + { + $geometry = [ + 'type' => 'MultiPoint', + 'coordinates' => [ + [28.9803, 41.0086], + [29.0097, 41.0422], + [28.8493, 41.0136] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test valid MultiLineString geometry + */ + public function testValidMultiLineString(): void + { + $geometry = [ + 'type' => 'MultiLineString', + 'coordinates' => [ + [[28.97, 41.00], [28.99, 41.02]], + [[29.00, 41.03], [29.02, 41.05]] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test valid MultiPolygon geometry + */ + public function testValidMultiPolygon(): void + { + $geometry = [ + 'type' => 'MultiPolygon', + 'coordinates' => [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test valid GeometryCollection + */ + public function testValidGeometryCollection(): void + { + $geometry = [ + 'type' => 'GeometryCollection', + 'geometries' => [ + ['type' => 'Point', 'coordinates' => [28.9803, 41.0086]], + ['type' => 'LineString', 'coordinates' => [[28.97, 41.00], [28.99, 41.02]]] + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } + + /** + * Test GeometryCollection with invalid geometry + */ + public function testGeometryCollectionWithInvalidGeometry(): void + { + $geometry = [ + 'type' => 'GeometryCollection', + 'geometries' => [ + ['type' => 'Point', 'coordinates' => [28.9803, 41.0086]], + ['type' => 'Point', 'coordinates' => [200, 41.0086]] // Invalid longitude + ] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + } + + /** + * Test missing type field + */ + public function testMissingType(): void + { + $geometry = [ + 'coordinates' => [28.9803, 41.0086] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('type', strtolower($result['error'])); + } + + /** + * Test invalid geometry type + */ + public function testInvalidType(): void + { + $geometry = [ + 'type' => 'InvalidType', + 'coordinates' => [28.9803, 41.0086] + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('unknown', strtolower($result['error'])); + } + + /** + * Test non-array input + */ + public function testNonArrayInput(): void + { + $result = $this->noneDB->validateGeoJSON('not an array'); + $this->assertFalse($result['valid']); + } + + /** + * Test Point with 3D coordinates (z-value allowed) + */ + public function testPoint3D(): void + { + $geometry = [ + 'type' => 'Point', + 'coordinates' => [28.9803, 41.0086, 100] // With elevation + ]; + + $result = $this->noneDB->validateGeoJSON($geometry); + $this->assertTrue($result['valid']); + } +} diff --git a/tests/Unit/GeometryOperationsTest.php b/tests/Unit/GeometryOperationsTest.php new file mode 100644 index 0000000..d672700 --- /dev/null +++ b/tests/Unit/GeometryOperationsTest.php @@ -0,0 +1,429 @@ +getPrivateMethod('calculateMBR'); + + $point = [ + 'type' => 'Point', + 'coordinates' => [28.9803, 41.0086] + ]; + + $mbr = $method->invoke($this->noneDB, $point); + + // For a point, MBR is the point itself + $this->assertEquals(28.9803, $mbr[0]); // minLon + $this->assertEquals(41.0086, $mbr[1]); // minLat + $this->assertEquals(28.9803, $mbr[2]); // maxLon + $this->assertEquals(41.0086, $mbr[3]); // maxLat + } + + /** + * Test MBR calculation for LineString + */ + public function testCalculateMBRLineString(): void + { + $method = $this->getPrivateMethod('calculateMBR'); + + $line = [ + 'type' => 'LineString', + 'coordinates' => [ + [28.0, 40.0], + [29.0, 41.0], + [30.0, 40.5] + ] + ]; + + $mbr = $method->invoke($this->noneDB, $line); + + $this->assertEquals(28.0, $mbr[0]); // minLon + $this->assertEquals(40.0, $mbr[1]); // minLat + $this->assertEquals(30.0, $mbr[2]); // maxLon + $this->assertEquals(41.0, $mbr[3]); // maxLat + } + + /** + * Test MBR calculation for Polygon + */ + public function testCalculateMBRPolygon(): void + { + $method = $this->getPrivateMethod('calculateMBR'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [28.0, 40.0], + [30.0, 40.0], + [30.0, 42.0], + [28.0, 42.0], + [28.0, 40.0] + ]] + ]; + + $mbr = $method->invoke($this->noneDB, $polygon); + + $this->assertEquals(28.0, $mbr[0]); + $this->assertEquals(40.0, $mbr[1]); + $this->assertEquals(30.0, $mbr[2]); + $this->assertEquals(42.0, $mbr[3]); + } + + /** + * Test MBR union + */ + public function testMBRUnion(): void + { + $method = $this->getPrivateMethod('mbrUnion'); + + $mbr1 = [28.0, 40.0, 29.0, 41.0]; + $mbr2 = [29.5, 40.5, 30.0, 42.0]; + + $union = $method->invoke($this->noneDB, $mbr1, $mbr2); + + $this->assertEquals(28.0, $union[0]); // min of minLons + $this->assertEquals(40.0, $union[1]); // min of minLats + $this->assertEquals(30.0, $union[2]); // max of maxLons + $this->assertEquals(42.0, $union[3]); // max of maxLats + } + + /** + * Test MBR overlap detection (overlapping) + */ + public function testMBROverlapsTrue(): void + { + $method = $this->getPrivateMethod('mbrOverlaps'); + + $mbr1 = [28.0, 40.0, 30.0, 42.0]; + $mbr2 = [29.0, 41.0, 31.0, 43.0]; + + $overlaps = $method->invoke($this->noneDB, $mbr1, $mbr2); + $this->assertTrue($overlaps); + } + + /** + * Test MBR overlap detection (non-overlapping) + */ + public function testMBROverlapsFalse(): void + { + $method = $this->getPrivateMethod('mbrOverlaps'); + + $mbr1 = [28.0, 40.0, 29.0, 41.0]; + $mbr2 = [30.0, 42.0, 31.0, 43.0]; + + $overlaps = $method->invoke($this->noneDB, $mbr1, $mbr2); + $this->assertFalse($overlaps); + } + + /** + * Test MBR area calculation + */ + public function testMBRArea(): void + { + $method = $this->getPrivateMethod('mbrArea'); + + $mbr = [0.0, 0.0, 2.0, 3.0]; // 2 x 3 = 6 + + $area = $method->invoke($this->noneDB, $mbr); + $this->assertEquals(6.0, $area); + } + + // ========== Haversine Distance Tests ========== + + /** + * Test Haversine distance between two points + */ + public function testHaversineDistance(): void + { + $method = $this->getPrivateMethod('haversineDistance'); + + // Istanbul to Ankara (approx 350 km = 350000 meters) + $distance = $method->invoke($this->noneDB, 28.9784, 41.0082, 32.8597, 39.9334); + + // Allow 10km tolerance (in meters) + $this->assertGreaterThan(340000, $distance); + $this->assertLessThan(360000, $distance); + } + + /** + * Test Haversine distance for same point (should be 0) + */ + public function testHaversineDistanceSamePoint(): void + { + $method = $this->getPrivateMethod('haversineDistance'); + + $distance = $method->invoke($this->noneDB, 28.9784, 41.0082, 28.9784, 41.0082); + + $this->assertEquals(0, $distance); + } + + /** + * Test circle to bounding box conversion + */ + public function testCircleToBBox(): void + { + $method = $this->getPrivateMethod('circleToBBox'); + + // 100km radius circle around Istanbul + $bbox = $method->invoke($this->noneDB, 28.9784, 41.0082, 100); + + // BBox should extend approximately 100km in each direction + // At ~41 degrees latitude, 1 degree longitude ≈ 85km + // 100km / 85km ≈ 1.2 degrees longitude change + $this->assertLessThan(28.9784, $bbox[0]); // minLon + $this->assertLessThan(41.0082, $bbox[1]); // minLat + $this->assertGreaterThan(28.9784, $bbox[2]); // maxLon + $this->assertGreaterThan(41.0082, $bbox[3]); // maxLat + } + + // ========== Point in Polygon Tests ========== + + /** + * Test point inside polygon + */ + public function testPointInPolygonInside(): void + { + $method = $this->getPrivateMethod('pointInPolygon'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [0, 0], [10, 0], [10, 10], [0, 10], [0, 0] + ]] + ]; + + $inside = $method->invoke($this->noneDB, 5, 5, $polygon); + $this->assertTrue($inside); + } + + /** + * Test point outside polygon + */ + public function testPointInPolygonOutside(): void + { + $method = $this->getPrivateMethod('pointInPolygon'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [0, 0], [10, 0], [10, 10], [0, 10], [0, 0] + ]] + ]; + + $inside = $method->invoke($this->noneDB, 15, 5, $polygon); + $this->assertFalse($inside); + } + + /** + * Test point on polygon edge + */ + public function testPointOnPolygonEdge(): void + { + $method = $this->getPrivateMethod('pointInPolygon'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [0, 0], [10, 0], [10, 10], [0, 10], [0, 0] + ]] + ]; + + // Point on edge should be considered inside + $inside = $method->invoke($this->noneDB, 5, 0, $polygon); + // Edge behavior may vary, but typically inside + $this->assertTrue($inside); + } + + /** + * Test point inside polygon with hole + */ + public function testPointInPolygonWithHole(): void + { + $method = $this->getPrivateMethod('pointInPolygon'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [ + [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], // Outer + [[3, 3], [7, 3], [7, 7], [3, 7], [3, 3]] // Hole + ] + ]; + + // Point in outer ring but outside hole + $inside1 = $method->invoke($this->noneDB, 1, 1, $polygon); + $this->assertTrue($inside1); + + // Point inside hole + $inside2 = $method->invoke($this->noneDB, 5, 5, $polygon); + $this->assertFalse($inside2); + } + + // ========== Line Segment Intersection Tests ========== + + /** + * Test intersecting line segments + */ + public function testLineSegmentsIntersect(): void + { + $method = $this->getPrivateMethod('lineSegmentsIntersect'); + + // X pattern + $intersects = $method->invoke( + $this->noneDB, + [0, 0], [10, 10], // Line 1: diagonal + [0, 10], [10, 0] // Line 2: other diagonal + ); + + $this->assertTrue($intersects); + } + + /** + * Test non-intersecting parallel lines + */ + public function testLineSegmentsParallel(): void + { + $method = $this->getPrivateMethod('lineSegmentsIntersect'); + + $intersects = $method->invoke( + $this->noneDB, + [0, 0], [10, 0], // Horizontal line at y=0 + [0, 5], [10, 5] // Horizontal line at y=5 + ); + + $this->assertFalse($intersects); + } + + /** + * Test non-intersecting non-parallel lines + */ + public function testLineSegmentsNoIntersect(): void + { + $method = $this->getPrivateMethod('lineSegmentsIntersect'); + + $intersects = $method->invoke( + $this->noneDB, + [0, 0], [5, 5], // Short diagonal + [6, 0], [10, 4] // Other segment far away + ); + + $this->assertFalse($intersects); + } + + // ========== Polygon Intersection Tests ========== + + /** + * Test overlapping polygons + */ + public function testPolygonsIntersectOverlapping(): void + { + $method = $this->getPrivateMethod('polygonsIntersect'); + + $poly1 = [ + 'type' => 'Polygon', + 'coordinates' => [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] + ]; + $poly2 = [ + 'type' => 'Polygon', + 'coordinates' => [[[5, 5], [15, 5], [15, 15], [5, 15], [5, 5]]] + ]; + + $intersects = $method->invoke($this->noneDB, $poly1, $poly2); + $this->assertTrue($intersects); + } + + /** + * Test non-overlapping polygons + */ + public function testPolygonsIntersectNonOverlapping(): void + { + $method = $this->getPrivateMethod('polygonsIntersect'); + + $poly1 = [ + 'type' => 'Polygon', + 'coordinates' => [[[0, 0], [5, 0], [5, 5], [0, 5], [0, 0]]] + ]; + $poly2 = [ + 'type' => 'Polygon', + 'coordinates' => [[[10, 10], [15, 10], [15, 15], [10, 15], [10, 10]]] + ]; + + $intersects = $method->invoke($this->noneDB, $poly1, $poly2); + $this->assertFalse($intersects); + } + + /** + * Test one polygon inside another + */ + public function testPolygonsIntersectContained(): void + { + $method = $this->getPrivateMethod('polygonsIntersect'); + + $poly1 = [ + 'type' => 'Polygon', + 'coordinates' => [[[0, 0], [20, 0], [20, 20], [0, 20], [0, 0]]] + ]; + $poly2 = [ + 'type' => 'Polygon', + 'coordinates' => [[[5, 5], [15, 5], [15, 15], [5, 15], [5, 5]]] + ]; + + $intersects = $method->invoke($this->noneDB, $poly1, $poly2); + $this->assertTrue($intersects); + } + + // ========== Centroid Tests ========== + + /** + * Test centroid calculation for Point + */ + public function testGetGeometryCentroidPoint(): void + { + $method = $this->getPrivateMethod('getGeometryCentroid'); + + $point = [ + 'type' => 'Point', + 'coordinates' => [28.9803, 41.0086] + ]; + + $centroid = $method->invoke($this->noneDB, $point); + + $this->assertEquals(28.9803, $centroid[0]); + $this->assertEquals(41.0086, $centroid[1]); + } + + /** + * Test centroid calculation for Polygon + */ + public function testGetGeometryCentroidPolygon(): void + { + $method = $this->getPrivateMethod('getGeometryCentroid'); + + $polygon = [ + 'type' => 'Polygon', + 'coordinates' => [[ + [0, 0], [10, 0], [10, 10], [0, 10], [0, 0] + ]] + ]; + + $centroid = $method->invoke($this->noneDB, $polygon); + + // Centroid of a square should be at center + $this->assertEquals(5.0, $centroid[0]); + $this->assertEquals(5.0, $centroid[1]); + } +} diff --git a/tests/operator_benchmark.php b/tests/operator_benchmark.php new file mode 100644 index 0000000..68f0750 --- /dev/null +++ b/tests/operator_benchmark.php @@ -0,0 +1,260 @@ + 'benchmark_secret_key', + 'dbDir' => $testDir, + 'autoCreateDB' => true +]); + +$recordCounts = [100, 500, 1000, 5000]; + +foreach ($recordCounts as $count) { + echo "=== Testing with $count records ===\n"; + + // Clean for each test + foreach (glob($testDir . '*') as $file) { + if (is_file($file)) unlink($file); + } + noneDB::clearStaticCache(); + + // Generate test data + $data = []; + $departments = ['Engineering', 'Design', 'Marketing', 'Sales', 'Support']; + $statuses = ['active', 'inactive', 'pending']; + $roles = ['admin', 'user', 'moderator', 'guest']; + + for ($i = 0; $i < $count; $i++) { + $data[] = [ + 'name' => "User $i", + 'email' => "user$i@test.com", + 'age' => rand(18, 65), + 'salary' => rand(30000, 150000), + 'department' => $departments[array_rand($departments)], + 'status' => $statuses[array_rand($statuses)], + 'role' => $roles[array_rand($roles)], + 'score' => rand(0, 100) / 10, + 'tags' => rand(0, 1) ? ['developer', 'senior'] : null, + 'location' => [ + 'type' => 'Point', + 'coordinates' => [ + 28.8 + (rand(0, 200) / 1000), // Istanbul area + 40.9 + (rand(0, 200) / 1000) + ] + ] + ]; + } + + // Insert data + $start = microtime(true); + $db->insert('benchmark', $data); + $insertTime = (microtime(true) - $start) * 1000; + echo "Insert $count records: " . round($insertTime, 2) . " ms\n"; + + // Create spatial index + $start = microtime(true); + $db->createSpatialIndex('benchmark', 'location'); + $spatialIndexTime = (microtime(true) - $start) * 1000; + echo "Create spatial index: " . round($spatialIndexTime, 2) . " ms\n\n"; + + // ========== COMPARISON OPERATORS ========== + echo "--- Comparison Operators ---\n"; + + // $gt operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['age' => ['$gt' => 30]]) + ->get(); + $gtTime = (microtime(true) - $start) * 1000; + echo "\$gt (age > 30): " . round($gtTime, 2) . " ms (" . count($results) . " results)\n"; + + // $gte + $lte (range) + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['salary' => ['$gte' => 50000, '$lte' => 100000]]) + ->get(); + $rangeTime = (microtime(true) - $start) * 1000; + echo "\$gte + \$lte (salary 50k-100k): " . round($rangeTime, 2) . " ms (" . count($results) . " results)\n"; + + // $in operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['department' => ['$in' => ['Engineering', 'Design']]]) + ->get(); + $inTime = (microtime(true) - $start) * 1000; + echo "\$in (2 departments): " . round($inTime, 2) . " ms (" . count($results) . " results)\n"; + + // $nin operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['role' => ['$nin' => ['guest', 'user']]]) + ->get(); + $ninTime = (microtime(true) - $start) * 1000; + echo "\$nin (exclude 2 roles): " . round($ninTime, 2) . " ms (" . count($results) . " results)\n"; + + // $ne operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['status' => ['$ne' => 'inactive']]) + ->get(); + $neTime = (microtime(true) - $start) * 1000; + echo "\$ne (status != inactive): " . round($neTime, 2) . " ms (" . count($results) . " results)\n"; + + // $like operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['email' => ['$like' => 'test.com$']]) + ->get(); + $likeTime = (microtime(true) - $start) * 1000; + echo "\$like (email ends with): " . round($likeTime, 2) . " ms (" . count($results) . " results)\n"; + + // $exists operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where(['tags' => ['$exists' => true]]) + ->get(); + $existsTime = (microtime(true) - $start) * 1000; + echo "\$exists (has tags): " . round($existsTime, 2) . " ms (" . count($results) . " results)\n"; + + // Complex multi-operator + $start = microtime(true); + $results = $db->query('benchmark') + ->where([ + 'age' => ['$gte' => 25, '$lte' => 45], + 'salary' => ['$gt' => 60000], + 'department' => ['$in' => ['Engineering', 'Design', 'Marketing']], + 'status' => 'active' + ]) + ->get(); + $complexTime = (microtime(true) - $start) * 1000; + echo "Complex (4 conditions): " . round($complexTime, 2) . " ms (" . count($results) . " results)\n"; + + echo "\n--- Spatial Queries ---\n"; + + // withinDistance only + $start = microtime(true); + $results = $db->withinDistance('benchmark', 'location', 28.9, 41.0, 10000); + $withinDistTime = (microtime(true) - $start) * 1000; + echo "withinDistance (10km): " . round($withinDistTime, 2) . " ms (" . count($results) . " results)\n"; + + // nearest only + $start = microtime(true); + $results = $db->nearest('benchmark', 'location', 28.9, 41.0, 10); + $nearestTime = (microtime(true) - $start) * 1000; + echo "nearest(10): " . round($nearestTime, 2) . " ms\n"; + + // withinBBox only + $start = microtime(true); + $results = $db->withinBBox('benchmark', 'location', 28.85, 40.95, 28.95, 41.05); + $bboxTime = (microtime(true) - $start) * 1000; + echo "withinBBox: " . round($bboxTime, 2) . " ms (" . count($results) . " results)\n"; + + echo "\n--- Spatial + Operator Combinations ---\n"; + + // Spatial + simple where + $start = microtime(true); + $results = $db->query('benchmark') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where(['status' => 'active']) + ->get(); + $spatialSimpleTime = (microtime(true) - $start) * 1000; + echo "spatial + simple where: " . round($spatialSimpleTime, 2) . " ms (" . count($results) . " results)\n"; + + // Spatial + $gte + $start = microtime(true); + $results = $db->query('benchmark') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where(['salary' => ['$gte' => 70000]]) + ->get(); + $spatialGteTime = (microtime(true) - $start) * 1000; + echo "spatial + \$gte: " . round($spatialGteTime, 2) . " ms (" . count($results) . " results)\n"; + + // Spatial + $in + $start = microtime(true); + $results = $db->query('benchmark') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where(['department' => ['$in' => ['Engineering', 'Design']]]) + ->get(); + $spatialInTime = (microtime(true) - $start) * 1000; + echo "spatial + \$in: " . round($spatialInTime, 2) . " ms (" . count($results) . " results)\n"; + + // Spatial + range + $start = microtime(true); + $results = $db->query('benchmark') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where(['age' => ['$gte' => 25, '$lte' => 40]]) + ->get(); + $spatialRangeTime = (microtime(true) - $start) * 1000; + echo "spatial + range: " . round($spatialRangeTime, 2) . " ms (" . count($results) . " results)\n"; + + // Spatial + complex operators + $start = microtime(true); + $results = $db->query('benchmark') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where([ + 'status' => 'active', + 'salary' => ['$gte' => 50000, '$lte' => 120000], + 'department' => ['$in' => ['Engineering', 'Design', 'Marketing']], + 'score' => ['$gte' => 5.0] + ]) + ->get(); + $spatialComplexTime = (microtime(true) - $start) * 1000; + echo "spatial + complex (4 ops): " . round($spatialComplexTime, 2) . " ms (" . count($results) . " results)\n"; + + // Spatial + operators + sort + $start = microtime(true); + $results = $db->query('benchmark') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where(['salary' => ['$gte' => 50000]]) + ->withDistance('location', 28.9, 41.0) + ->sort('_distance', 'asc') + ->limit(20) + ->get(); + $spatialSortTime = (microtime(true) - $start) * 1000; + echo "spatial + ops + sort + limit: " . round($spatialSortTime, 2) . " ms (" . count($results) . " results)\n"; + + // nearest + operators + $start = microtime(true); + $results = $db->query('benchmark') + ->nearest('location', 28.9, 41.0, 50) + ->where([ + 'status' => 'active', + 'salary' => ['$gt' => 60000] + ]) + ->limit(10) + ->get(); + $nearestOpsTime = (microtime(true) - $start) * 1000; + echo "nearest + ops + limit: " . round($nearestOpsTime, 2) . " ms (" . count($results) . " results)\n"; + + echo "\n"; +} + +// Cleanup +foreach (glob($testDir . '*') as $file) { + if (is_file($file)) unlink($file); +} +rmdir($testDir); + +echo "=== Benchmark Summary ===\n"; +echo "- Comparison operators add minimal overhead (<5ms for most operations)\n"; +echo "- Spatial + operator combinations work efficiently\n"; +echo "- R-tree indexing provides O(log n) spatial queries\n"; +echo "- Complex multi-operator queries scale well\n"; diff --git a/tests/spatial_benchmark.php b/tests/spatial_benchmark.php new file mode 100644 index 0000000..69c9473 --- /dev/null +++ b/tests/spatial_benchmark.php @@ -0,0 +1,110 @@ + 'benchmark_secret', + 'dbDir' => $testDir, + 'autoCreateDB' => true +]); + +// Generate test data +$recordCounts = [100, 500, 1000]; + +foreach ($recordCounts as $count) { + echo "--- Testing with $count records ---\n"; + + // Clean for each test + foreach (glob($testDir . '*') as $file) { + if (is_file($file)) unlink($file); + } + noneDB::clearStaticCache(); + + // Generate random Istanbul area locations + $data = []; + for ($i = 0; $i < $count; $i++) { + $data[] = [ + 'name' => "Location $i", + 'location' => [ + 'type' => 'Point', + 'coordinates' => [ + 28.8 + (mt_rand(0, 100) / 500), // Lon: 28.8-29.0 + 40.9 + (mt_rand(0, 100) / 500) // Lat: 40.9-41.1 + ] + ] + ]; + } + + // 1. Batch Insert (without spatial index) + $start = microtime(true); + $db->insert('benchmark_locations', $data); + $insertTime = (microtime(true) - $start) * 1000; + echo "Insert $count records: " . round($insertTime, 2) . " ms\n"; + + // 2. Create Spatial Index + $start = microtime(true); + $db->createSpatialIndex('benchmark_locations', 'location'); + $indexTime = (microtime(true) - $start) * 1000; + echo "Create spatial index: " . round($indexTime, 2) . " ms\n"; + + // 3. withinDistance query + $start = microtime(true); + $results = $db->withinDistance('benchmark_locations', 'location', 28.9, 41.0, 5000); + $queryTime = (microtime(true) - $start) * 1000; + echo "withinDistance (5km): " . round($queryTime, 2) . " ms (" . count($results) . " results)\n"; + + // 4. nearest() query + $start = microtime(true); + $results = $db->nearest('benchmark_locations', 'location', 28.9, 41.0, 10); + $nearestTime = (microtime(true) - $start) * 1000; + echo "nearest(10): " . round($nearestTime, 2) . " ms\n"; + + // 5. withinBBox query + $start = microtime(true); + $results = $db->withinBBox('benchmark_locations', 'location', 28.85, 40.95, 28.95, 41.05); + $bboxTime = (microtime(true) - $start) * 1000; + echo "withinBBox: " . round($bboxTime, 2) . " ms (" . count($results) . " results)\n"; + + // 6. Query Builder with spatial + where filter + $start = microtime(true); + $results = $db->query('benchmark_locations') + ->withinDistance('location', 28.9, 41.0, 10000) + ->where(['name' => ['$like' => 'Location 1%']]) + ->get(); + $combinedTime = (microtime(true) - $start) * 1000; + echo "Combined query (spatial+where): " . round($combinedTime, 2) . " ms (" . count($results) . " results)\n"; + + echo "\n"; +} + +// Cleanup +foreach (glob($testDir . '*') as $file) { + if (is_file($file)) unlink($file); +} + +echo "Benchmark completed!\n"; +echo "\n=== Optimization Summary ===\n"; +echo "- Parent pointer map: O(1) parent lookup (was O(n))\n"; +echo "- Linear split: O(n) seed selection (was O(n²))\n"; +echo "- Dirty flag pattern: Single disk write per batch (was n writes)\n"; +echo "- Distance memoization: Cached haversine calculations\n"; +echo "- Centroid caching: Cached geometry centroids\n"; +echo "- Node size 32: Fewer tree levels and splits\n"; +echo "- Adaptive nearest(): Exponential radius expansion\n";