1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync\Command;
4:
5: use Salient\Cli\Exception\CliInvalidArgumentsException;
6: use Salient\Cli\CliOption;
7: use Salient\Contract\Cli\CliOptionType;
8: use Salient\Contract\Sync\Exception\SyncEntityNotFoundExceptionInterface;
9: use Salient\Contract\Sync\DeferralPolicy;
10: use Salient\Contract\Sync\HydrationPolicy;
11: use Salient\Contract\Sync\SyncEntityInterface;
12: use Salient\Contract\Sync\SyncProviderInterface;
13: use Salient\Core\Facade\Console;
14: use Salient\Sync\SyncSerializeRules;
15: use Salient\Sync\SyncUtil;
16: use Salient\Utility\Arr;
17: use Salient\Utility\File;
18: use Salient\Utility\Get;
19: use Salient\Utility\Inflect;
20: use Salient\Utility\Json;
21: use InvalidArgumentException;
22:
23: /**
24: * A generic sync entity retrieval command
25: *
26: * @api
27: *
28: * @template T of SyncEntityInterface
29: */
30: final class GetSyncEntity extends AbstractSyncCommand
31: {
32: private string $EntityBasename = '';
33: /** @var class-string<SyncEntityInterface> */
34: private string $Entity = SyncEntityInterface::class;
35: private ?string $EntityId = null;
36: private ?string $ProviderBasename = null;
37: /** @var class-string<SyncProviderInterface> */
38: private string $Provider = SyncProviderInterface::class;
39: /** @var string[] */
40: private array $Filter = [];
41: private bool $Shallow = false;
42: private bool $IncludeCanonicalId = false;
43: private bool $IncludeMeta = false;
44: private bool $Stream = false;
45: /** @var string[] */
46: private array $Field = [];
47: private bool $Csv = false;
48:
49: // --
50:
51: /** @var array<string,string> */
52: private array $Fields;
53:
54: public function getDescription(): string
55: {
56: return 'Get entities from ' . (
57: $this->Entities
58: ? 'a registered provider'
59: : 'a provider'
60: );
61: }
62:
63: protected function getOptionList(): iterable
64: {
65: $entityBuilder = CliOption::build()
66: ->name('entity')
67: ->required();
68:
69: $entityIdBuilder = CliOption::build()
70: ->name('entity_id')
71: ->description(<<<EOF
72: The unique identifier of an entity to request
73:
74: If not given, a list of entities is requested.
75: EOF)
76: ->optionType(CliOptionType::VALUE_POSITIONAL)
77: ->bindTo($this->EntityId);
78:
79: $providerBuilder = CliOption::build()
80: ->long('provider')
81: ->short('p')
82: ->valueName('provider');
83:
84: if ($this->Entities) {
85: yield from [
86: $entityBuilder
87: ->optionType(CliOptionType::ONE_OF_POSITIONAL)
88: ->allowedValues(array_keys($this->Entities))
89: ->bindTo($this->EntityBasename),
90: $entityIdBuilder,
91: $providerBuilder
92: ->description(<<<EOF
93: The provider to request entities from
94:
95: If not given, the entity's default provider is used.
96: EOF)
97: ->optionType(CliOptionType::ONE_OF)
98: ->allowedValues(array_keys($this->EntityProviders))
99: ->bindTo($this->ProviderBasename),
100: ];
101: } else {
102: yield from [
103: $entityBuilder
104: ->description('The fully-qualified name of the entity to request')
105: ->optionType(CliOptionType::VALUE_POSITIONAL)
106: ->bindTo($this->Entity),
107: $entityIdBuilder,
108: $providerBuilder
109: ->description('The fully-qualified name of the provider to request entities from')
110: ->optionType(CliOptionType::VALUE)
111: ->required()
112: ->bindTo($this->Provider),
113: ];
114: }
115:
116: yield from [
117: CliOption::build()
118: ->long('filter')
119: ->short('f')
120: ->valueName('term=value')
121: ->description('Apply a filter to the request')
122: ->optionType(CliOptionType::VALUE)
123: ->multipleAllowed()
124: ->bindTo($this->Filter),
125: CliOption::build()
126: ->long('shallow')
127: ->description('Do not resolve entity relationships')
128: ->bindTo($this->Shallow),
129: CliOption::build()
130: ->long('include-canonical-id')
131: ->short('I')
132: ->description('Include canonical_id in the output')
133: ->bindTo($this->IncludeCanonicalId),
134: CliOption::build()
135: ->long('include-meta')
136: ->short('M')
137: ->description('Include meta values in the output')
138: ->bindTo($this->IncludeMeta),
139: CliOption::build()
140: ->long('stream')
141: ->short('s')
142: ->description('Output a stream of entities')
143: ->bindTo($this->Stream),
144: CliOption::build()
145: ->long('field')
146: ->short('F')
147: ->valueName('(<field>|<field>=<title>)')
148: ->description('Limit output to the given fields, e.g. "id,user.id=user id,title"')
149: ->optionType(CliOptionType::VALUE)
150: ->multipleAllowed()
151: ->bindTo($this->Field),
152: CliOption::build()
153: ->long('csv')
154: ->short('c')
155: ->description('Generate CSV output (implies `--shallow` if `--field` is not given)')
156: ->bindTo($this->Csv),
157: ];
158:
159: yield from $this->getGlobalOptionList();
160: }
161:
162: protected function run(string ...$args)
163: {
164: $this->startRun();
165:
166: $this->Fields = [];
167:
168: if ($this->Entities) {
169: /** @var class-string<T> */
170: $entity = $this->Entities[$this->EntityBasename];
171:
172: $provider = $this->ProviderBasename ?? array_search(
173: $this->App->getName(SyncUtil::getEntityTypeProvider($entity, SyncUtil::getStore($this->App))),
174: $this->Providers,
175: );
176:
177: if ($provider === false) {
178: throw new CliInvalidArgumentsException(
179: sprintf('no default provider: %s', $entity)
180: );
181: }
182:
183: $provider = $this->Providers[$provider];
184: } else {
185: /** @var class-string<T> */
186: $entity = $this->Entity;
187: $provider = $this->Provider;
188:
189: if (!is_a(
190: $this->App->getName($entity),
191: SyncEntityInterface::class,
192: true
193: )) {
194: throw new CliInvalidArgumentsException(sprintf(
195: '%s does not implement %s',
196: $entity,
197: SyncEntityInterface::class,
198: ));
199: }
200:
201: if (!is_a(
202: $this->App->getName($provider),
203: SyncProviderInterface::class,
204: true
205: )) {
206: throw new CliInvalidArgumentsException(sprintf(
207: '%s does not implement %s',
208: $provider,
209: SyncProviderInterface::class,
210: ));
211: }
212:
213: if (!$this->App->has($provider)) {
214: $this->App->singleton($provider);
215: }
216: }
217:
218: $provider = $this->App->get($provider);
219:
220: $entityProvider = SyncUtil::getEntityTypeProvider($entity, SyncUtil::getStore($this->App));
221: if (!$provider instanceof $entityProvider) {
222: throw new CliInvalidArgumentsException(sprintf(
223: '%s does not service %s',
224: get_class($provider),
225: $entity,
226: ));
227: }
228:
229: try {
230: $filter = Get::filter($this->Filter);
231: } catch (InvalidArgumentException $ex) {
232: throw new CliInvalidArgumentsException(sprintf(
233: 'invalid filter (%s)',
234: $ex->getMessage(),
235: ));
236: }
237:
238: $context = $provider->getContext();
239: if ($this->Shallow || ($this->Csv && !$this->Field)) {
240: $context = $context
241: ->withDeferralPolicy(DeferralPolicy::DO_NOT_RESOLVE)
242: ->withHydrationPolicy(HydrationPolicy::SUPPRESS);
243: }
244:
245: /** @var SyncSerializeRules<T> */
246: $rules = $entity::getSerializeRules()
247: ->withIncludeCanonicalId($this->IncludeCanonicalId)
248: ->withIncludeMeta($this->IncludeMeta);
249:
250: if ($this->Field) {
251: foreach ($this->Field as $field) {
252: $field = explode('=', $field, 2);
253: $this->Fields[$field[0]] = $field[1] ?? $field[0];
254: }
255: }
256:
257: Console::info(
258: sprintf('Retrieving from %s:', $provider->getName()),
259: $this->Store->getEntityTypeUri($entity)
260: . ($this->EntityId === null ? '' : '/' . $this->EntityId)
261: );
262:
263: if ($this->EntityId === null) {
264: $result = $this->Stream
265: ? $provider->with($entity, $context)->getList($filter)
266: : $provider->with($entity, $context)->getListA($filter);
267: } else {
268: try {
269: $result = $provider->with($entity, $context)->get($this->EntityId, $filter);
270: } catch (SyncEntityNotFoundExceptionInterface $ex) {
271: throw new CliInvalidArgumentsException(
272: sprintf(
273: 'entity not found: %s (%s)',
274: $this->EntityId,
275: $ex->getMessage(),
276: )
277: );
278: }
279: }
280:
281: $stdout = Console::getStdoutTarget();
282: $tty = $stdout->isTty();
283:
284: if ($this->Csv) {
285: if ($this->EntityId !== null) {
286: $result = [$result];
287: }
288:
289: File::writeCsv(
290: 'php://output',
291: $result,
292: true,
293: null,
294: fn(SyncEntityInterface $entity, int $index) =>
295: $this->serialize($entity, $rules, !$index),
296: $count,
297: $tty ? $stdout->getEol() : "\r\n",
298: !$tty,
299: !$tty,
300: );
301: } elseif ($this->Stream && $this->EntityId === null) {
302: $eol = $tty ? $stdout->getEol() : \PHP_EOL;
303: $count = 0;
304: foreach ($result as $entity) {
305: echo Json::prettyPrint($this->serialize($entity, $rules, !$count), 0, $eol) . $eol;
306: $count++;
307: }
308: } else {
309: if ($this->EntityId !== null) {
310: $result = [$result];
311: }
312:
313: $output = [];
314: $count = 0;
315: foreach ($result as $entity) {
316: $output[] = $this->serialize($entity, $rules, !$count);
317: $count++;
318: }
319:
320: if ($this->EntityId !== null) {
321: $output = array_shift($output);
322: }
323:
324: $eol = $tty ? $stdout->getEol() : \PHP_EOL;
325: echo Json::prettyPrint($output, 0, $eol) . $eol;
326: }
327:
328: Console::summary(Inflect::format(
329: $count,
330: '{{#}} {{#:entity}} retrieved',
331: ));
332: }
333:
334: /**
335: * @param T $entity
336: * @param SyncSerializeRules<T> $rules
337: * @return mixed[]
338: */
339: private function serialize(
340: SyncEntityInterface $entity,
341: SyncSerializeRules $rules,
342: bool $check = false
343: ): array {
344: $entity = $entity->toArrayWith($rules);
345: if (!$this->Fields) {
346: return $entity;
347: }
348: foreach ($this->Fields as $field => $name) {
349: if ($check && !Arr::has($entity, $field)) {
350: throw new CliInvalidArgumentsException(sprintf(
351: 'Invalid field: %s',
352: $field,
353: ));
354: }
355: $result[$name] = Arr::get($entity, $field, null);
356: }
357: return $result;
358: }
359: }
360: