@rvoh/psychic 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.
8
8
  import generateResource from '../generate/resource.js';
9
9
  import Watcher from '../watcher/Watcher.js';
10
10
  const INDENT = ' ';
11
- const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
11
+ const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
12
12
  ${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
13
13
  ${INDENT}
14
14
  ${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
@@ -68,7 +68,16 @@ ${INDENT}
68
68
  ${INDENT} use the fully qualified model name (matching its path under src/app/models/):
69
69
  ${INDENT} User:belongs_to # creates user_id column + BelongsTo association
70
70
  ${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
71
- ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)`;
71
+ ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
72
+ ${INDENT}
73
+ ${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
74
+ ${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
75
+ ${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
76
+ ${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
77
+ ${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
78
+ ${INDENT} # (strips the namespace from the property/association names while keeping
79
+ ${INDENT} # the namespaced model reference intact)
80
+ ${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
72
81
  export default class PsychicCLI {
73
82
  static provide(program, { initializePsychicApp, seedDb, }) {
74
83
  DreamCLI.generateDreamCli(program, {
@@ -9,16 +9,46 @@ import { camelize } from '@rvoh/dream/utils';
9
9
  * paramSafe column allowlist) share one canonical interpretation of the
10
10
  * tokens — and one canonical casing (camelCase) for the resulting attribute
11
11
  * name.
12
+ *
13
+ * Supports the `Model@alias:belongs_to` shorthand for renaming the FK
14
+ * association. When `@alias` is present, the camelized alias becomes
15
+ * `attributeName`; otherwise (legacy form `Model:belongs_to`) the
16
+ * attribute name is derived from the model's last namespace segment.
17
+ *
18
+ * Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
19
+ * implementation rather than a shared import so neither package leaks an
20
+ * internal CLI helper through its public API surface).
12
21
  */
13
22
  export default function parseAttribute(attribute) {
14
- const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
15
- if (!rawAttributeName || !rawAttributeType)
23
+ const segments = attribute.split(':');
24
+ const rawSegmentOne = segments[0];
25
+ const rawAttributeType = segments[1];
26
+ if (!rawSegmentOne || !rawAttributeType)
16
27
  return null;
28
+ // Extract optional `@alias` from segment-1 (used by the belongs_to FK alias
29
+ // shorthand, e.g., `InternalUser@canceled_by:belongs_to`).
30
+ let rawAttributeName = rawSegmentOne;
31
+ let aliasName;
32
+ const atIndex = rawSegmentOne.indexOf('@');
33
+ if (atIndex !== -1) {
34
+ rawAttributeName = rawSegmentOne.slice(0, atIndex);
35
+ const rawAlias = rawSegmentOne.slice(atIndex + 1);
36
+ if (!rawAttributeName || !rawAlias)
37
+ return null;
38
+ aliasName = rawAlias;
39
+ }
40
+ // Pop trailing `optional` keyword from descriptors so it doesn't get
41
+ // mistaken for enum values or other positional descriptors.
42
+ const descriptors = segments.slice(2);
43
+ if (descriptors[descriptors.length - 1] === 'optional')
44
+ descriptors.pop();
45
+ const enumValues = descriptors[1];
17
46
  const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
18
47
  // Handle belongs_to relationships
19
48
  if (sanitizedAttrType === 'belongsto') {
20
- // For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
21
- const attributeName = camelize(rawAttributeName.split('/').pop());
49
+ // For belongs_to relationships, prefer the explicit alias when present;
50
+ // otherwise convert "Ticketing/Ticket" → "ticket".
51
+ const attributeName = aliasName ? camelize(aliasName) : camelize(rawAttributeName.split('/').pop());
22
52
  return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
23
53
  }
24
54
  // Skip _type and _id columns, but not belongs_to relationships
@@ -8,7 +8,7 @@ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.
8
8
  import generateResource from '../generate/resource.js';
9
9
  import Watcher from '../watcher/Watcher.js';
10
10
  const INDENT = ' ';
11
- const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
11
+ const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
12
12
  ${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
13
13
  ${INDENT}
14
14
  ${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
@@ -68,7 +68,16 @@ ${INDENT}
68
68
  ${INDENT} use the fully qualified model name (matching its path under src/app/models/):
69
69
  ${INDENT} User:belongs_to # creates user_id column + BelongsTo association
70
70
  ${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
71
- ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)`;
71
+ ${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
72
+ ${INDENT}
73
+ ${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
74
+ ${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
75
+ ${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
76
+ ${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
77
+ ${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
78
+ ${INDENT} # (strips the namespace from the property/association names while keeping
79
+ ${INDENT} # the namespaced model reference intact)
80
+ ${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
72
81
  export default class PsychicCLI {
73
82
  static provide(program, { initializePsychicApp, seedDb, }) {
74
83
  DreamCLI.generateDreamCli(program, {
@@ -9,16 +9,46 @@ import { camelize } from '@rvoh/dream/utils';
9
9
  * paramSafe column allowlist) share one canonical interpretation of the
10
10
  * tokens — and one canonical casing (camelCase) for the resulting attribute
11
11
  * name.
12
+ *
13
+ * Supports the `Model@alias:belongs_to` shorthand for renaming the FK
14
+ * association. When `@alias` is present, the camelized alias becomes
15
+ * `attributeName`; otherwise (legacy form `Model:belongs_to`) the
16
+ * attribute name is derived from the model's last namespace segment.
17
+ *
18
+ * Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
19
+ * implementation rather than a shared import so neither package leaks an
20
+ * internal CLI helper through its public API surface).
12
21
  */
13
22
  export default function parseAttribute(attribute) {
14
- const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
15
- if (!rawAttributeName || !rawAttributeType)
23
+ const segments = attribute.split(':');
24
+ const rawSegmentOne = segments[0];
25
+ const rawAttributeType = segments[1];
26
+ if (!rawSegmentOne || !rawAttributeType)
16
27
  return null;
28
+ // Extract optional `@alias` from segment-1 (used by the belongs_to FK alias
29
+ // shorthand, e.g., `InternalUser@canceled_by:belongs_to`).
30
+ let rawAttributeName = rawSegmentOne;
31
+ let aliasName;
32
+ const atIndex = rawSegmentOne.indexOf('@');
33
+ if (atIndex !== -1) {
34
+ rawAttributeName = rawSegmentOne.slice(0, atIndex);
35
+ const rawAlias = rawSegmentOne.slice(atIndex + 1);
36
+ if (!rawAttributeName || !rawAlias)
37
+ return null;
38
+ aliasName = rawAlias;
39
+ }
40
+ // Pop trailing `optional` keyword from descriptors so it doesn't get
41
+ // mistaken for enum values or other positional descriptors.
42
+ const descriptors = segments.slice(2);
43
+ if (descriptors[descriptors.length - 1] === 'optional')
44
+ descriptors.pop();
45
+ const enumValues = descriptors[1];
17
46
  const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
18
47
  // Handle belongs_to relationships
19
48
  if (sanitizedAttrType === 'belongsto') {
20
- // For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
21
- const attributeName = camelize(rawAttributeName.split('/').pop());
49
+ // For belongs_to relationships, prefer the explicit alias when present;
50
+ // otherwise convert "Ticketing/Ticket" → "ticket".
51
+ const attributeName = aliasName ? camelize(aliasName) : camelize(rawAttributeName.split('/').pop());
22
52
  return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
23
53
  }
24
54
  // Skip _type and _id columns, but not belongs_to relationships
@@ -14,5 +14,14 @@ export interface ParsedAttribute {
14
14
  * paramSafe column allowlist) share one canonical interpretation of the
15
15
  * tokens — and one canonical casing (camelCase) for the resulting attribute
16
16
  * name.
17
+ *
18
+ * Supports the `Model@alias:belongs_to` shorthand for renaming the FK
19
+ * association. When `@alias` is present, the camelized alias becomes
20
+ * `attributeName`; otherwise (legacy form `Model:belongs_to`) the
21
+ * attribute name is derived from the model's last namespace segment.
22
+ *
23
+ * Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
24
+ * implementation rather than a shared import so neither package leaks an
25
+ * internal CLI helper through its public API surface).
17
26
  */
18
27
  export default function parseAttribute(attribute: string): ParsedAttribute | null;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.3.0",
5
+ "version": "3.4.0",
6
6
  "author": "RVOHealth",
7
7
  "repository": {
8
8
  "type": "git",