1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Persistence;
6:
7: use Atk4\Data\Exception;
8: use Atk4\Data\Model;
9: use Atk4\Data\Persistence;
10:
11: /**
12: * Implements persistence driver that can save data and load from CSV file.
13: * This basic driver only offers the load/save. It does not offer conditions or
14: * id-specific operations. You can only use a single persistence object with
15: * a single file.
16: *
17: * $p = new Persistence\Csv('file.csv');
18: * $m = new MyModel($p);
19: * $data = $m->export();
20: *
21: * Alternatively you can write into a file. First operation you perform on
22: * the persistence will determine the mode.
23: *
24: * $p = new Persistence\Csv('file.csv');
25: * $m = new MyModel($p);
26: * $m->import($data);
27: */
28: class Csv extends Persistence
29: {
30: /** @var string Name of the file. */
31: public $file;
32:
33: /** @var int Line in CSV file. */
34: public $line = 0;
35:
36: /** @var resource|null File handle, when the file is opened. */
37: public $handle;
38:
39: /**
40: * Mode of operation. 'r' for reading and 'w' for writing.
41: * If you manually set this operation, it will be used for file opening.
42: *
43: * @var string
44: */
45: public $mode;
46:
47: /** @var string Delimiter in CSV file. */
48: public $delimiter = ',';
49: /** @var string Enclosure in CSV file. */
50: public $enclosure = '"';
51: /** @var string Escape character in CSV file. */
52: public $escapeChar = '\\';
53:
54: /** @var array<int, string>|null Array of field names. */
55: public ?array $header = null;
56:
57: /**
58: * @param array<string, mixed> $defaults
59: */
60: public function __construct(string $file, array $defaults = [])
61: {
62: $this->file = $file;
63: $this->setDefaults($defaults);
64: }
65:
66: public function __destruct()
67: {
68: $this->closeFile();
69: }
70:
71: /**
72: * Override this method and open handle yourself if you want to
73: * reposition or load some extra columns on the top.
74: *
75: * @param string $mode 'r' or 'w'
76: */
77: public function openFile(string $mode = 'r'): void
78: {
79: if (!$this->handle) {
80: $this->handle = fopen($this->file, $mode);
81: if ($this->handle === false) {
82: throw (new Exception('Cannot open CSV file'))
83: ->addMoreInfo('file', $this->file)
84: ->addMoreInfo('mode', $mode);
85: }
86: }
87: }
88:
89: public function closeFile(): void
90: {
91: if ($this->handle) {
92: fclose($this->handle);
93: $this->handle = null;
94: $this->header = null;
95: }
96: }
97:
98: /**
99: * Returns one line of CSV file as array.
100: *
101: * @return ($reindexWithHeader is true ? array<string, string> : array<int, string>)|null
102: */
103: public function getLine(bool $reindexWithHeader): ?array
104: {
105: $data = fgetcsv($this->handle, 0, $this->delimiter, $this->enclosure, $this->escapeChar);
106: if ($data === false) {
107: return null;
108: }
109:
110: ++$this->line;
111:
112: if ($reindexWithHeader) {
113: $data = array_combine($this->header, $data);
114: }
115:
116: return $data;
117: }
118:
119: /**
120: * Writes array as one record to CSV file.
121: *
122: * @param array<int, string> $data
123: */
124: public function putLine(array $data): void
125: {
126: $ok = fputcsv($this->handle, $data, $this->delimiter, $this->enclosure, $this->escapeChar);
127: if ($ok === false) {
128: throw new Exception('Cannot write to CSV file');
129: }
130: }
131:
132: /**
133: * When load operation starts, this will open file and read
134: * the first line. This line is then used to identify columns.
135: */
136: public function loadHeader(): void
137: {
138: $this->openFile('r');
139:
140: $header = $this->getLine(false);
141: --$this->line; // because we don't want to count header line
142:
143: $this->initializeHeader($header);
144: }
145:
146: /**
147: * When load operation starts, this will open file and read
148: * the first line. This line is then used to identify columns.
149: */
150: public function saveHeader(Model $model): void
151: {
152: $this->openFile('w');
153:
154: $header = [];
155: foreach (array_keys($model->getFields()) as $name) {
156: if ($model->idField && $name === $model->idField) {
157: continue;
158: }
159:
160: $header[] = $name;
161: }
162:
163: $this->putLine($header);
164:
165: $this->initializeHeader($header);
166: }
167:
168: /**
169: * Remembers $this->header so that the data can be easier mapped.
170: *
171: * @param array<int, string> $header
172: */
173: public function initializeHeader(array $header): void
174: {
175: // removes forbidden symbols from header (field names)
176: $this->header = array_map(static function (string $name): string {
177: return preg_replace('~[^a-z0-9_-]+~i', '_', $name);
178: }, $header);
179: }
180:
181: #[\Override]
182: public function tryLoad(Model $model, $id): ?array
183: {
184: $model->assertIsModel();
185:
186: if ($id !== self::ID_LOAD_ANY) {
187: throw new Exception('CSV Persistence does not support other than LOAD ANY mode'); // @TODO
188: }
189:
190: if (!$this->mode) {
191: $this->mode = 'r';
192: } elseif ($this->mode === 'w') {
193: throw new Exception('Currently writing records, so loading is not possible');
194: }
195:
196: if (!$this->handle) {
197: $this->loadHeader();
198: }
199:
200: $data = $this->getLine(true);
201: if ($data === null) {
202: return null;
203: }
204:
205: $data = $this->typecastLoadRow($model, $data);
206: if ($model->idField) {
207: $data[$model->idField] = $this->line;
208: }
209:
210: return $data;
211: }
212:
213: /**
214: * @return \Traversable<array<string, mixed>>
215: */
216: public function prepareIterator(Model $model): \Traversable
217: {
218: if (!$this->mode) {
219: $this->mode = 'r';
220: } elseif ($this->mode === 'w') {
221: throw new Exception('Currently writing records, so loading is not possible');
222: }
223:
224: if (!$this->handle) {
225: $this->loadHeader();
226: }
227:
228: while (true) {
229: $data = $this->getLine(true);
230: if ($data === null) {
231: break;
232: }
233:
234: $data = $this->typecastLoadRow($model, $data);
235: if ($model->idField) {
236: $data[$model->idField] = $this->line;
237: }
238:
239: yield $data;
240: }
241: }
242:
243: #[\Override]
244: protected function insertRaw(Model $model, array $dataRaw)
245: {
246: if (!$this->mode) {
247: $this->mode = 'w';
248: } elseif ($this->mode === 'r') {
249: throw new Exception('Currently reading records, so writing is not possible');
250: }
251:
252: if (!$this->handle) {
253: $this->saveHeader($model->getModel(true));
254: }
255:
256: $line = [];
257: foreach ($this->header as $name) {
258: $line[] = $dataRaw[$name];
259: }
260:
261: $this->putLine($line);
262:
263: return $model->idField ? $dataRaw[$model->idField] : null;
264: }
265:
266: /**
267: * Export all DataSet.
268: *
269: * @param array<int, string>|null $fields
270: *
271: * @return array<int, array<string, mixed>>
272: */
273: public function export(Model $model, array $fields = null): array
274: {
275: $data = [];
276: foreach ($model as $entity) {
277: $entityData = $entity->get();
278: $data[] = $fields !== null ? array_intersect_key($entityData, array_flip($fields)) : $entityData;
279: }
280:
281: // need to close file otherwise file pointer is at the end of file
282: $this->closeFile();
283:
284: return $data;
285: }
286: }
287: