Skip to content

Commit 3bef754

Browse files
Merge pull request #114 from keyqcloud/feature/190-p1-column-projection
feat(190): column projection in the query engine (P1 slice 1)
2 parents 05b3e4b + 4db9a53 commit 3bef754

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

src/Core/DBI.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,17 +1202,42 @@ public static function count($table, $condition = null, $join = null)
12021202
* @param integer $id
12031203
* @param string $condition
12041204
*/
1205-
public static function select($table, $id = null, $condition = null, $join = null)
1205+
public static function select($table, $id = null, $condition = null, $join = null, $fields = null)
12061206
{
12071207
$cacheKey = null;
12081208

1209+
// Column projection (KYTE-#190): when $fields is a non-empty array of
1210+
// column names, read only those columns instead of `table`.* — this is
1211+
// how large TEXT/BLOB columns are kept out of list reads. Backward
1212+
// compatible: null (the default) preserves the historical SELECT *.
1213+
// Column names are validated against a strict identifier pattern (they
1214+
// originate from the model struct, but we never interpolate anything
1215+
// unvalidated into SQL). `id` is always included so every projected row
1216+
// stays identifiable/hydratable. An empty-after-validation list falls
1217+
// back to * rather than emitting invalid SQL.
1218+
$columnList = "`$table`.*";
1219+
if (is_array($fields) && count($fields) > 0) {
1220+
$cols = [];
1221+
$seen = [];
1222+
foreach (array_merge(['id'], $fields) as $f) {
1223+
if (is_string($f) && preg_match('/^[A-Za-z0-9_]+$/', $f) && !isset($seen[$f])) {
1224+
$seen[$f] = true;
1225+
$cols[] = "`$table`.`$f`";
1226+
}
1227+
}
1228+
if (count($cols) > 0) {
1229+
$columnList = implode(', ', $cols);
1230+
}
1231+
}
1232+
12091233
// Check cache if enabled
12101234
if (self::$cacheEnabled) {
12111235
$cacheKey = md5(serialize([
12121236
'table' => $table,
12131237
'id' => $id,
12141238
'condition' => $condition,
12151239
'join' => $join,
1240+
'columns' => $columnList,
12161241
'db' => self::$useAppDB ? 'app' : 'main'
12171242
]));
12181243

@@ -1228,7 +1253,7 @@ public static function select($table, $id = null, $condition = null, $join = nul
12281253
// db connection
12291254
$con = self::getConnection();
12301255

1231-
$query = "SELECT `$table`.* FROM `$table`";
1256+
$query = "SELECT $columnList FROM `$table`";
12321257

12331258
if (is_array($join)) {
12341259
foreach ($join as $j) {

src/Core/Model.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ class Model
2929
*/
3030
private $eagerLoad = [];
3131

32+
/**
33+
* Column projection (KYTE-#190). When set to a non-empty array of column
34+
* names, retrieve() reads only those columns (plus `id`) instead of every
35+
* column — the mechanism for keeping large TEXT/BLOB columns out of list
36+
* reads. null (the default) preserves the historical SELECT * behaviour.
37+
* @var array|null
38+
*/
39+
private $projection = null;
40+
3241
public function __construct($model, $page_size = null, $page_num = null, $search_fields = null, $search_value = null) {
3342
$this->kyte_model = $model;
3443
$this->page_size = $page_size;
@@ -58,6 +67,26 @@ public function with($relations) {
5867
return $this; // Fluent interface for chaining
5968
}
6069

70+
/**
71+
* Restrict retrieve() to a subset of columns (KYTE-#190 column projection).
72+
* `id` is always included by the query layer so rows stay identifiable.
73+
* Pass a single field name or an array of field names. Passing null/empty
74+
* clears the projection (back to all columns).
75+
*
76+
* @param string|array|null $fields Column name(s) from the model struct
77+
* @return self For method chaining
78+
*/
79+
public function select($fields) {
80+
if (is_string($fields)) {
81+
$this->projection = [$fields];
82+
} elseif (is_array($fields) && count($fields) > 0) {
83+
$this->projection = $fields;
84+
} else {
85+
$this->projection = null;
86+
}
87+
return $this; // Fluent interface for chaining
88+
}
89+
6190
private function addJoin(&$join, $table, $main_table_idx, $table_idx, $table_alias = null, $join_type = 'LEFT') {
6291
foreach ($join as $j) {
6392
if (
@@ -275,7 +304,7 @@ public function retrieve($field = null, $value = null, $isLike = false, $conditi
275304
}
276305
}
277306

278-
$data = \Kyte\Core\DBI::select($this->kyte_model['name'], null, $sql, $join);
307+
$data = \Kyte\Core\DBI::select($this->kyte_model['name'], null, $sql, $join, $this->projection);
279308

280309
foreach ($data as $item) {
281310
$obj = new \Kyte\Core\ModelObject($this->kyte_model);

tests/ModelTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,26 @@ public function testCreateTable() {
152152
$data = \Kyte\Core\DBI::query('SELECT * FROM `TestTable`;');
153153
$this->assertTrue(count($data) > 0 ? true : false);
154154

155+
// test column projection (KYTE-#190): DBI::select with a field list
156+
// reads only the requested columns (plus `id`, always included),
157+
// keeping large/other columns out of the result set.
158+
$projected = \Kyte\Core\DBI::select('TestTable', null, null, null, ['name']);
159+
$this->assertNotEmpty($projected);
160+
$this->assertArrayHasKey('id', $projected[0]); // id auto-included
161+
$this->assertArrayHasKey('name', $projected[0]); // requested column
162+
$this->assertArrayNotHasKey('temperature', $projected[0]); // projected out
163+
$this->assertArrayNotHasKey('category', $projected[0]); // projected out
164+
// backward compatible: no field list still returns every column
165+
$full = \Kyte\Core\DBI::select('TestTable');
166+
$this->assertArrayHasKey('temperature', $full[0]);
167+
// an empty / all-invalid field list falls back to SELECT * (never emits
168+
// invalid SQL), and an injection-y column name is dropped
169+
$fallback = \Kyte\Core\DBI::select('TestTable', null, null, null, []);
170+
$this->assertArrayHasKey('temperature', $fallback[0]);
171+
$sanitized = \Kyte\Core\DBI::select('TestTable', null, null, null, ['name', 'temperature; DROP TABLE x']);
172+
$this->assertArrayHasKey('name', $sanitized[0]);
173+
$this->assertArrayNotHasKey('temperature', $sanitized[0]); // malformed name dropped
174+
155175
// test udpate entry
156176
$model = new \Kyte\Core\ModelObject(TestTable);
157177
$this->assertTrue($model->retrieve('name', 'Test'));
@@ -234,6 +254,17 @@ public function testCreateTable() {
234254
$this->assertTrue($model->retrieve('category', 'Test'));
235255
$this->assertEquals(2, $model->count());
236256

257+
// test Model column projection (KYTE-#190 fluent select): projected
258+
// rows hydrate the requested column but not the omitted ones.
259+
$model = new \Kyte\Core\Model(TestTable);
260+
$this->assertTrue($model->select(['name'])->retrieve('category', 'Test'));
261+
$this->assertEquals(2, $model->count());
262+
$projObj = $model->first();
263+
$this->assertIsObject($projObj);
264+
$this->assertNotFalse($projObj->getParam('name')); // projected column present
265+
$this->assertNotFalse($projObj->getParam('id')); // id always included
266+
$this->assertFalse($projObj->getParam('temperature')); // omitted column not hydrated
267+
237268
// test retrieve with conditions
238269
$model = new \Kyte\Core\Model(TestTable);
239270
$this->assertTrue($model->retrieve('category', 'Test', false, [['field' => 'name', 'value' => 'Test2']]));

0 commit comments

Comments
 (0)